This commit is contained in:
Niall O'Brien 2026-05-31 04:55:46 +03:00 committed by GitHub
commit d4d841d995
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 905 additions and 31 deletions

View file

@ -292,6 +292,14 @@
"ctrl-alt-shift-pagedown": "agent::ScrollOutputToNextMessage",
},
},
{
"context": "AcpThread > MessageEditor > Editor",
"use_key_equivalents": true,
"bindings": {
"up": "agent::PromptHistoryPrevious",
"down": "agent::PromptHistoryNext",
},
},
{
"context": "AcpThread > Editor",
"use_key_equivalents": true,

View file

@ -335,6 +335,14 @@
"ctrl-alt-pagedown": "agent::ScrollOutputToNextMessage",
},
},
{
"context": "AcpThread > MessageEditor > Editor",
"use_key_equivalents": true,
"bindings": {
"up": "agent::PromptHistoryPrevious",
"down": "agent::PromptHistoryNext",
},
},
{
"context": "AcpThread > Editor",
"use_key_equivalents": true,

View file

@ -293,6 +293,14 @@
"ctrl-alt-shift-pagedown": "agent::ScrollOutputToNextMessage",
},
},
{
"context": "AcpThread > MessageEditor > Editor",
"use_key_equivalents": true,
"bindings": {
"up": "agent::PromptHistoryPrevious",
"down": "agent::PromptHistoryNext",
},
},
{
"context": "AcpThread > Editor",
"use_key_equivalents": true,

View file

@ -159,6 +159,8 @@
//
// Default: true
"restore_on_file_reopen": true,
// Whether to restore open tabs and pane layout per active git branch.
"restore_tabs_per_branch": true,
// Whether to automatically close files that have been deleted on disk.
"close_on_file_delete": false,
// Whether toggling a panel (e.g. with its keyboard shortcut) also closes

View file

@ -259,6 +259,10 @@ actions!(
EditFirstQueuedMessage,
/// Clears all messages from the queue.
ClearMessageQueue,
/// Recalls the previous submitted prompt in the focused message editor.
PromptHistoryPrevious,
/// Moves toward newer prompt history in the focused message editor.
PromptHistoryNext,
/// Opens the permission granularity dropdown for the current tool call.
OpenPermissionDropdown,
/// Toggles thinking mode for models that support extended thinking.

View file

@ -975,7 +975,7 @@ impl ThreadView {
pub fn handle_message_editor_event(
&mut self,
_editor: &Entity<MessageEditor>,
editor: &Entity<MessageEditor>,
event: &MessageEditorEvent,
window: &mut Window,
cx: &mut Context<Self>,
@ -1002,6 +1002,20 @@ impl ThreadView {
MessageEditorEvent::Send => self.send(window, cx),
MessageEditorEvent::SendImmediately => self.interrupt_and_send(window, cx),
MessageEditorEvent::Cancel => self.cancel_generation(cx),
MessageEditorEvent::PromptHistoryPreviousRequested => {
let history = self.submitted_prompt_history(cx);
editor.update(cx, |editor, cx| {
editor.set_prompt_history(history);
editor.navigate_prompt_history_previous(window, cx);
});
}
MessageEditorEvent::PromptHistoryNextRequested => {
let history = self.submitted_prompt_history(cx);
editor.update(cx, |editor, cx| {
editor.set_prompt_history(history);
editor.navigate_prompt_history_next(window, cx);
});
}
MessageEditorEvent::Focus => {
self.cancel_editing(&Default::default(), window, cx);
}
@ -1083,6 +1097,18 @@ impl ThreadView {
!self.local_queued_messages.is_empty()
}
fn submitted_prompt_history(&self, cx: &App) -> Vec<Vec<acp::ContentBlock>> {
self.thread
.read(cx)
.entries()
.iter()
.filter_map(|entry| match entry {
AgentThreadEntry::UserMessage(user_message) => Some(user_message.chunks.clone()),
_ => None,
})
.collect()
}
pub fn is_imported_thread(&self, cx: &App) -> bool {
let Some(thread) = self.as_native_thread(cx) else {
return false;
@ -1146,6 +1172,14 @@ impl ThreadView {
ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => {
self.cancel_editing(&Default::default(), window, cx);
}
ViewEvent::MessageEditorEvent(
_editor,
MessageEditorEvent::PromptHistoryPreviousRequested,
) => {}
ViewEvent::MessageEditorEvent(
_editor,
MessageEditorEvent::PromptHistoryNextRequested,
) => {}
ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::SlashAutocompleteOpened) => {
}
ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::InputAttempted { .. }) => {}

View file

@ -188,10 +188,18 @@ pub struct MessageEditor {
session_capabilities: SharedSessionCapabilities,
agent_id: AgentId,
thread_store: Option<Entity<ThreadStore>>,
prompt_history: Vec<Vec<acp::ContentBlock>>,
prompt_history_state: PromptHistoryState,
_subscriptions: Vec<Subscription>,
_parse_slash_command_task: Task<()>,
}
#[derive(Default)]
struct PromptHistoryState {
saved_draft: Option<Vec<acp::ContentBlock>>,
history_index: Option<usize>,
}
#[derive(Clone, Debug)]
pub enum InputAttempt {
Text(Arc<str>),
@ -213,6 +221,8 @@ pub enum MessageEditorEvent {
attempt: InputAttempt,
cursor_offset: usize,
},
PromptHistoryPreviousRequested,
PromptHistoryNextRequested,
}
impl EventEmitter<MessageEditorEvent> for MessageEditor {}
@ -605,6 +615,8 @@ impl MessageEditor {
session_capabilities,
agent_id,
thread_store,
prompt_history: Vec::new(),
prompt_history_state: PromptHistoryState::default(),
_subscriptions: subscriptions,
_parse_slash_command_task: Task::ready(()),
}
@ -920,6 +932,7 @@ impl MessageEditor {
}
pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.reset_prompt_history_navigation();
self.editor.update(cx, |editor, cx| {
editor.clear(window, cx);
editor.remove_creases(
@ -940,6 +953,7 @@ impl MessageEditor {
editor.clear_inlay_hints(cx);
});
}
self.reset_prompt_history_navigation();
cx.emit(MessageEditorEvent::Send)
}
@ -1006,9 +1020,48 @@ impl MessageEditor {
editor.clear_inlay_hints(cx);
});
self.reset_prompt_history_navigation();
cx.emit(MessageEditorEvent::SendImmediately)
}
fn prompt_history_previous(
&mut self,
_: &crate::PromptHistoryPrevious,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if self.has_visible_completions_menu(cx) {
cx.propagate();
return;
}
if !self.cursor_is_at_buffer_start(cx) {
cx.propagate();
return;
}
cx.emit(MessageEditorEvent::PromptHistoryPreviousRequested);
}
fn prompt_history_next(
&mut self,
_: &crate::PromptHistoryNext,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if self.has_visible_completions_menu(cx) {
cx.propagate();
return;
}
if !self.is_navigating_prompt_history() {
cx.propagate();
return;
}
cx.emit(MessageEditorEvent::PromptHistoryNextRequested);
}
fn chat_with_follow(
&mut self,
_: &ChatWithFollow,
@ -1850,6 +1903,96 @@ impl MessageEditor {
self.editor.read(cx).text(cx)
}
fn reset_prompt_history_navigation(&mut self) {
self.prompt_history_state = PromptHistoryState::default();
}
pub fn set_prompt_history(&mut self, prompt_history: Vec<Vec<acp::ContentBlock>>) {
self.prompt_history = prompt_history;
}
fn has_visible_completions_menu(&self, cx: &App) -> bool {
self.editor.read(cx).has_visible_completions_menu()
}
fn is_navigating_prompt_history(&self) -> bool {
self.prompt_history_state.history_index.is_some()
}
fn cursor_is_at_buffer_start(&self, cx: &App) -> bool {
let editor = self.editor.read(cx);
let snapshot = editor.buffer().read(cx).snapshot(cx);
let selection = editor.selections.newest_anchor();
let start_offset = selection.start.to_offset(&snapshot).0;
let end_offset = selection.end.to_offset(&snapshot).0;
start_offset == 0 && end_offset == 0
}
fn set_message_from_history(
&mut self,
message: Vec<acp::ContentBlock>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, cx| {
editor.clear(window, cx);
});
self.insert_message_blocks(message, false, window, cx);
self.set_cursor_offset(0, window, cx);
}
pub fn navigate_prompt_history_previous(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
if self.prompt_history.is_empty() {
return false;
}
let next_index = match self.prompt_history_state.history_index {
Some(index) if index > 0 => index - 1,
Some(_) => return false,
None => {
self.prompt_history_state.saved_draft =
Some(self.draft_content_blocks_snapshot(cx));
self.prompt_history.len() - 1
}
};
self.prompt_history_state.history_index = Some(next_index);
let message = self.prompt_history[next_index].clone();
self.set_message_from_history(message, window, cx);
true
}
pub fn navigate_prompt_history_next(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
let Some(current_index) = self.prompt_history_state.history_index else {
return false;
};
if current_index + 1 < self.prompt_history.len() {
let next_index = current_index + 1;
self.prompt_history_state.history_index = Some(next_index);
let message = self.prompt_history[next_index].clone();
self.set_message_from_history(message, window, cx);
return true;
}
let draft = self
.prompt_history_state
.saved_draft
.take()
.unwrap_or_default();
self.prompt_history_state.history_index = None;
self.set_message_from_history(draft, window, cx);
true
}
pub fn set_cursor_offset(
&mut self,
offset: usize,
@ -1893,6 +2036,16 @@ impl MessageEditor {
});
}
#[cfg(test)]
pub fn set_prompt_history_for_tests(
&mut self,
prompt_history: Vec<Vec<acp::ContentBlock>>,
_window: &mut Window,
_cx: &mut Context<Self>,
) {
self.set_prompt_history(prompt_history);
}
fn serialize_selection_with_mentions(
&self,
expand_empty_to_line: bool,
@ -1992,6 +2145,8 @@ impl Render for MessageEditor {
.on_action(cx.listener(Self::chat))
.on_action(cx.listener(Self::send_immediately))
.on_action(cx.listener(Self::chat_with_follow))
.on_action(cx.listener(Self::prompt_history_previous))
.on_action(cx.listener(Self::prompt_history_next))
.on_action(cx.listener(Self::cancel))
.capture_action(cx.listener(Self::copy))
.capture_action(cx.listener(Self::cut))
@ -5361,6 +5516,227 @@ mod tests {
);
}
#[gpui::test]
async fn test_prompt_history_previous_and_draft_restore(cx: &mut TestAppContext) {
init_test(cx);
let (message_editor, cx) = setup_message_editor(cx).await;
message_editor.update_in(cx, |message_editor, window, cx| {
message_editor.set_prompt_history_for_tests(
vec![
vec![acp::ContentBlock::Text(acp::TextContent::new(
"first prompt",
))],
vec![acp::ContentBlock::Text(acp::TextContent::new(
"second prompt",
))],
vec![acp::ContentBlock::Text(acp::TextContent::new(
"third prompt",
))],
],
window,
cx,
);
message_editor.set_text("draft prompt", window, cx);
message_editor.set_cursor_offset(0, window, cx);
});
message_editor.update_in(cx, |message_editor, window, cx| {
assert!(message_editor.navigate_prompt_history_previous(window, cx));
});
let text = message_editor.update(cx, |message_editor, cx| message_editor.text(cx));
assert_eq!(text, "third prompt");
message_editor.update_in(cx, |message_editor, window, cx| {
assert!(message_editor.navigate_prompt_history_previous(window, cx));
});
let text = message_editor.update(cx, |message_editor, cx| message_editor.text(cx));
assert_eq!(text, "second prompt");
message_editor.update_in(cx, |message_editor, window, cx| {
assert!(message_editor.navigate_prompt_history_next(window, cx));
});
let text = message_editor.update(cx, |message_editor, cx| message_editor.text(cx));
assert_eq!(text, "third prompt");
message_editor.update_in(cx, |message_editor, window, cx| {
assert!(message_editor.navigate_prompt_history_next(window, cx));
});
let text = message_editor.update(cx, |message_editor, cx| message_editor.text(cx));
assert_eq!(text, "draft prompt");
}
#[gpui::test]
async fn test_prompt_history_previous_requires_cursor_at_start(cx: &mut TestAppContext) {
init_test(cx);
let (message_editor, cx) = setup_message_editor(cx).await;
message_editor.update_in(cx, |message_editor, window, cx| {
message_editor.set_prompt_history_for_tests(
vec![vec![acp::ContentBlock::Text(acp::TextContent::new(
"previous prompt",
))]],
window,
cx,
);
message_editor.set_text("draft prompt", window, cx);
message_editor.set_cursor_offset(5, window, cx);
});
cx.dispatch_action(crate::PromptHistoryPrevious);
let text = message_editor.update(cx, |message_editor, cx| message_editor.text(cx));
assert_eq!(text, "draft prompt");
}
#[gpui::test]
async fn test_prompt_history_next_without_history_mode_is_noop(cx: &mut TestAppContext) {
init_test(cx);
let (message_editor, cx) = setup_message_editor(cx).await;
message_editor.update_in(cx, |message_editor, window, cx| {
message_editor.set_prompt_history_for_tests(
vec![vec![acp::ContentBlock::Text(acp::TextContent::new(
"previous prompt",
))]],
window,
cx,
);
message_editor.set_text("draft prompt", window, cx);
message_editor.set_cursor_offset(0, window, cx);
});
message_editor.update_in(cx, |message_editor, window, cx| {
assert!(!message_editor.navigate_prompt_history_next(window, cx));
});
let text = message_editor.update(cx, |message_editor, cx| message_editor.text(cx));
assert_eq!(text, "draft prompt");
}
#[gpui::test]
async fn test_prompt_history_send_immediately_resets_navigation(cx: &mut TestAppContext) {
init_test(cx);
let (message_editor, cx) = setup_message_editor(cx).await;
message_editor.update_in(cx, |message_editor, window, cx| {
message_editor.set_prompt_history_for_tests(
vec![vec![acp::ContentBlock::Text(acp::TextContent::new(
"previous prompt",
))]],
window,
cx,
);
message_editor.set_text("draft prompt", window, cx);
message_editor.set_cursor_offset(0, window, cx);
});
message_editor.update_in(cx, |message_editor, window, cx| {
assert!(message_editor.navigate_prompt_history_previous(window, cx));
});
message_editor.update_in(cx, |message_editor, _window, cx| {
message_editor.send_immediately(&crate::SendImmediately, _window, cx);
});
message_editor.update_in(cx, |message_editor, window, cx| {
message_editor.set_text("new draft", window, cx);
message_editor.set_cursor_offset(0, window, cx);
});
message_editor.update_in(cx, |message_editor, window, cx| {
assert!(!message_editor.navigate_prompt_history_next(window, cx));
});
let text = message_editor.update(cx, |message_editor, cx| message_editor.text(cx));
assert_eq!(text, "new draft");
}
#[gpui::test]
async fn test_prompt_history_does_not_override_completions_navigation(cx: &mut TestAppContext) {
init_test(cx);
let app_state = cx.update(AppState::test);
cx.update(|cx| {
editor::init(cx);
workspace::init(app_state.clone(), cx);
});
let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
let window =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = window
.read_with(cx, |mw, _| mw.workspace().clone())
.unwrap();
let mut cx = VisualTestContext::from_window(window.into(), cx);
let session_capabilities = Arc::new(RwLock::new(SessionCapabilities::from_acp_commands(
acp::PromptCapabilities::default(),
vec![
acp::AvailableCommand::new("quick-math", "2 + 2 = 4 - 1 = 3"),
acp::AvailableCommand::new("say-hello", "Say hello to whoever you want"),
],
)));
let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
let workspace_handle = cx.weak_entity();
let message_editor = cx.new(|cx| {
MessageEditor::new(
workspace_handle,
project.downgrade(),
None,
None,
session_capabilities.clone(),
"Test Agent".into(),
"Test",
EditorMode::AutoHeight {
max_lines: None,
min_lines: 1,
},
window,
cx,
)
});
workspace.active_pane().update(cx, |pane, cx| {
pane.add_item(
Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
true,
true,
None,
window,
cx,
);
});
message_editor.read(cx).focus_handle(cx).focus(window, cx);
message_editor
});
message_editor.update_in(&mut cx, |message_editor, window, cx| {
message_editor.set_prompt_history_for_tests(
vec![vec![acp::ContentBlock::Text(acp::TextContent::new(
"previous prompt",
))]],
window,
cx,
);
});
cx.simulate_input("/");
cx.dispatch_action(crate::PromptHistoryPrevious);
let (text, completions_visible) = message_editor.update(&mut cx, |message_editor, cx| {
(
message_editor.text(cx),
message_editor
.editor()
.read(cx)
.has_visible_completions_menu(),
)
});
assert_eq!(text, "/");
assert!(completions_visible);
}
#[gpui::test]
async fn test_append_message_preserves_mention_offset(cx: &mut TestAppContext) {
init_test(cx);

View file

@ -1033,6 +1033,7 @@ impl VsCodeSettings {
pane_split_direction_vertical: None,
resize_all_panels_in_dock: None,
restore_on_file_reopen: self.read_bool("workbench.editor.restoreViewState"),
restore_tabs_per_branch: None,
restore_on_startup: None,
window_decorations: None,
show_call_status_icon: None,

View file

@ -63,6 +63,10 @@ pub struct WorkspaceSettingsContent {
///
/// Default: true
pub restore_on_file_reopen: Option<bool>,
/// Whether to restore open tabs and pane layout per active git branch.
///
/// Default: true
pub restore_tabs_per_branch: Option<bool>,
/// The size of the workspace split drop targets on the outer edges.
/// Given as a fraction that will be multiplied by the smaller dimension of the workspace.
///

View file

@ -75,7 +75,7 @@ fn contains_wsl_path(paths: &PathList) -> bool {
.any(|path| util::paths::WslPath::from_path(path).is_some())
}
#[derive(Copy, Clone, Debug, PartialEq)]
#[derive(Copy, Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub(crate) struct SerializedAxis(pub(crate) gpui::Axis);
impl sqlez::bindable::StaticColumnCount for SerializedAxis {}
impl sqlez::bindable::Bind for SerializedAxis {
@ -1037,6 +1037,19 @@ impl Domain for WorkspaceDb {
ALTER TABLE workspaces ADD COLUMN identity_paths TEXT;
ALTER TABLE workspaces ADD COLUMN identity_paths_order TEXT;
),
sql!(
CREATE TABLE branch_workspace_layouts (
workspace_id INTEGER NOT NULL,
repository_path TEXT NOT NULL,
branch_name TEXT NOT NULL,
center_group TEXT NOT NULL,
timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (workspace_id, repository_path, branch_name),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
ON DELETE CASCADE
ON UPDATE CASCADE
) STRICT;
),
];
// Allow recovering from bad migration that was initially shipped to nightly
@ -2396,6 +2409,59 @@ impl WorkspaceDb {
Ok(())
}
pub(crate) async fn save_branch_workspace_layout(
&self,
workspace_id: WorkspaceId,
repository_path: Arc<Path>,
branch_name: String,
center_group: SerializedPaneGroup,
) -> Result<()> {
let repository_path = repository_path.to_string_lossy().into_owned();
let center_group = serde_json::to_string(&center_group)?;
self.write(move |conn| {
conn.exec_bound(sql!(
INSERT INTO branch_workspace_layouts(
workspace_id,
repository_path,
branch_name,
center_group
)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT DO UPDATE SET
center_group = ?4,
timestamp = CURRENT_TIMESTAMP
))?((workspace_id, repository_path, branch_name, center_group))?;
Ok(())
})
.await
}
pub(crate) async fn branch_workspace_layout(
&self,
workspace_id: WorkspaceId,
repository_path: Arc<Path>,
branch_name: String,
) -> Result<Option<SerializedPaneGroup>> {
let repository_path = repository_path.to_string_lossy().into_owned();
let mut center_groups = self.select_bound::<_, String>(sql!(
SELECT center_group
FROM branch_workspace_layouts
WHERE workspace_id = ?1
AND repository_path = ?2
AND branch_name = ?3
))?((workspace_id, repository_path, branch_name))
.context("loading branch workspace layout")?;
center_groups
.pop()
.map(|center_group| serde_json::from_str(&center_group))
.transpose()
.map_err(Into::into)
}
query! {
pub async fn update_timestamp(workspace_id: WorkspaceId) -> Result<()> {
UPDATE workspaces
@ -3277,6 +3343,90 @@ mod tests {
}
}
#[gpui::test]
async fn test_branch_workspace_layouts_are_scoped_by_branch() {
zlog::init_test();
let db =
WorkspaceDb::open_test_db("test_branch_workspace_layouts_are_scoped_by_branch").await;
let workspace = SerializedWorkspace {
id: WorkspaceId(1),
paths: PathList::new(&["/repo"]),
identity_paths: None,
location: SerializedWorkspaceLocation::Local,
center_group: Default::default(),
window_bounds: Default::default(),
display: Default::default(),
docks: Default::default(),
centered_layout: false,
bookmarks: Default::default(),
breakpoints: Default::default(),
session_id: None,
window_id: None,
user_toolchains: Default::default(),
};
db.save_workspace(workspace).await;
let branch_a_layout = SerializedPaneGroup::Pane(SerializedPane::new(
vec![SerializedItem::new("Editor", 1, true, false)],
true,
0,
));
let branch_b_layout = SerializedPaneGroup::Pane(SerializedPane::new(
vec![SerializedItem::new("Editor", 2, true, false)],
true,
0,
));
db.save_branch_workspace_layout(
WorkspaceId(1),
Path::new("/repo").into(),
"branch-a".to_owned(),
branch_a_layout.clone(),
)
.await
.unwrap();
db.save_branch_workspace_layout(
WorkspaceId(1),
Path::new("/repo").into(),
"branch-b".to_owned(),
branch_b_layout.clone(),
)
.await
.unwrap();
assert_eq!(
db.branch_workspace_layout(
WorkspaceId(1),
Path::new("/repo").into(),
"branch-a".to_owned()
)
.await
.unwrap(),
Some(branch_a_layout)
);
assert_eq!(
db.branch_workspace_layout(
WorkspaceId(1),
Path::new("/repo").into(),
"branch-b".to_owned()
)
.await
.unwrap(),
Some(branch_b_layout)
);
assert_eq!(
db.branch_workspace_layout(
WorkspaceId(1),
Path::new("/repo").into(),
"branch-c".to_owned()
)
.await
.unwrap(),
None
);
}
#[gpui::test]
async fn test_full_workspace_serialization() {
zlog::init_test();

View file

@ -230,7 +230,7 @@ impl Bind for DockData {
}
}
#[derive(Debug, PartialEq, Clone)]
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
pub(crate) enum SerializedPaneGroup {
Group {
axis: SerializedAxis,
@ -335,7 +335,7 @@ impl SerializedPaneGroup {
}
}
#[derive(Debug, PartialEq, Eq, Default, Clone)]
#[derive(Debug, PartialEq, Eq, Default, Clone, Serialize, Deserialize)]
pub struct SerializedPane {
pub(crate) active: bool,
pub(crate) children: Vec<SerializedItem>,
@ -420,7 +420,7 @@ pub type GroupId = i64;
pub type PaneId = i64;
pub type ItemId = u64;
#[derive(Debug, PartialEq, Eq, Clone)]
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
pub struct SerializedItem {
pub kind: Arc<str>,
pub item_id: ItemId,

View file

@ -93,12 +93,16 @@ pub use persistence::{
},
read_serialized_multi_workspaces,
};
use persistence::{SerializedWindowBounds, model::SerializedWorkspace};
use persistence::{
SerializedAxis, SerializedWindowBounds,
model::{SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace},
};
use postage::stream::Stream;
use project::{
DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId,
WorktreeSettings,
debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus},
git_store::{GitStoreEvent, RepositoryEvent},
project_settings::ProjectSettings,
toolchain_store::ToolchainStoreEvent,
trusted_worktrees::{RemoteHostLocation, TrustedWorktrees, TrustedWorktreesEvent},
@ -158,14 +162,8 @@ pub use workspace_settings::{
};
use zed_actions::{Spawn, feedback::FileBugReport, theme::ToggleMode};
use crate::security_modal::SecurityModal;
use crate::{dock::PanelSizeState, item::ItemBufferKind, notifications::NotificationId};
use crate::{
persistence::{
SerializedAxis,
model::{SerializedItem, SerializedPane, SerializedPaneGroup},
},
security_modal::SecurityModal,
};
pub const SERIALIZATION_THROTTLE_TIME: Duration = Duration::from_millis(200);
@ -1389,6 +1387,9 @@ pub struct Workspace {
_schedule_serialize_workspace: Option<Task<()>>,
_serialize_workspace_task: Option<Task<()>>,
_schedule_serialize_ssh_paths: Option<Task<()>>,
active_branch_session: Option<BranchSessionKey>,
_branch_session_task: Option<Task<Result<()>>>,
restoring_branch_session: bool,
pane_history_timestamp: Arc<AtomicUsize>,
bounds: Bounds<Pixels>,
pub centered_layout: bool,
@ -1434,6 +1435,12 @@ pub enum AutoWatch {
Paused,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct BranchSessionKey {
repository_path: Arc<Path>,
branch_name: String,
}
impl AutoWatch {
pub fn enabled(&self) -> bool {
matches!(self, AutoWatch::Active { .. } | AutoWatch::Paused)
@ -1749,7 +1756,11 @@ impl Workspace {
Self::serialize_items(&this, serializable_items_rx, cx).await
});
let git_store = project.read(cx).git_store().clone();
let active_branch_session = Self::branch_session_key_for_project(&project, cx);
let subscriptions = vec![
cx.subscribe_in(&git_store, window, Self::on_git_store_event),
cx.observe_window_activation(window, Self::on_window_activation_changed),
cx.observe_window_bounds(window, move |this, window, cx| {
if this.bounds_save_task_queued.is_some() {
@ -1831,6 +1842,9 @@ impl Workspace {
_schedule_serialize_workspace: None,
_serialize_workspace_task: None,
_schedule_serialize_ssh_paths: None,
active_branch_session,
_branch_session_task: None,
restoring_branch_session: false,
leader_updates_tx,
_subscriptions: subscriptions,
pane_history_timestamp,
@ -3470,6 +3484,14 @@ impl Workspace {
self.save_all_internal(SaveIntent::Close, true, window, cx)
}
fn prompt_to_save_or_discard_dirty_items_before_branch_switch(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<bool>> {
self.save_all_internal(SaveIntent::Close, false, window, cx)
}
fn save_all_internal(
&mut self,
mut save_intent: SaveIntent,
@ -3967,6 +3989,7 @@ impl Workspace {
if let Some(task) = self.close_all_internal(
true,
action.save_intent.unwrap_or(SaveIntent::Close),
false,
window,
cx,
) {
@ -3983,6 +4006,7 @@ impl Workspace {
if let Some(task) = self.close_all_internal(
false,
action.save_intent.unwrap_or(SaveIntent::Close),
false,
window,
cx,
) {
@ -4049,6 +4073,7 @@ impl Workspace {
&mut self,
retain_active_pane: bool,
save_intent: SaveIntent,
close_pinned: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<Task<Result<()>>> {
@ -4061,7 +4086,7 @@ impl Workspace {
pane.close_other_items(
&CloseOtherItems {
save_intent: None,
close_pinned: false,
close_pinned,
},
None,
window,
@ -4081,7 +4106,7 @@ impl Workspace {
pane.close_all_items(
&CloseAllItems {
save_intent: Some(save_intent),
close_pinned: false,
close_pinned,
},
window,
cx,
@ -6769,6 +6794,244 @@ impl Workspace {
.collect::<Vec<_>>()
}
fn branch_session_key_for_project(
project: &Entity<Project>,
cx: &App,
) -> Option<BranchSessionKey> {
let repository = project.read(cx).active_repository(cx)?;
let repository = repository.read(cx);
Some(BranchSessionKey {
repository_path: repository.work_directory_abs_path.clone(),
branch_name: repository.branch.as_ref()?.name().to_owned(),
})
}
fn on_git_store_event(
&mut self,
_git_store: &Entity<project::git_store::GitStore>,
event: &GitStoreEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !matches!(
event,
GitStoreEvent::RepositoryUpdated(_, RepositoryEvent::HeadChanged, true)
) {
return;
}
if self.restoring_branch_session || self._branch_session_task.is_some() {
return;
}
if !self.project.read(cx).is_local()
|| !workspace_settings::WorkspaceSettings::get_global(cx).restore_tabs_per_branch
{
self.active_branch_session = Self::branch_session_key_for_project(&self.project, cx);
return;
}
let previous_key = self.active_branch_session.clone();
let next_key = Self::branch_session_key_for_project(&self.project, cx);
if previous_key == next_key {
return;
}
let db = WorkspaceDb::global(cx);
self._branch_session_task = Some(cx.spawn_in(window, async move |this, cx| {
if let Some(previous_key) = previous_key.clone() {
let save = this.update_in(cx, |workspace, window, cx| {
workspace.save_branch_workspace_layout(previous_key, window, cx)
})?;
save.await.log_err();
}
let confirmed = this
.update_in(cx, |workspace, window, cx| {
workspace.prompt_to_save_or_discard_dirty_items_before_branch_switch(window, cx)
})?
.await?;
if !confirmed {
this.update(cx, |workspace, _| {
workspace.active_branch_session = previous_key;
workspace._branch_session_task = None;
})?;
return anyhow::Ok(());
}
if let Some(next_key) = next_key.clone() {
let layout = if let Some(workspace_id) =
this.read_with(cx, |workspace, _| workspace.database_id)?
{
db.branch_workspace_layout(
workspace_id,
next_key.repository_path.clone(),
next_key.branch_name.clone(),
)
.await
.log_err()
.flatten()
} else {
None
};
if let Some(layout) = layout {
this.update_in(cx, |workspace, window, cx| {
workspace.restore_branch_workspace_layout(layout, window, cx)
})?
.await
.log_err();
} else {
if let Some(task) = this.update_in(cx, |workspace, window, cx| {
workspace.close_all_internal(false, SaveIntent::Skip, true, window, cx)
})? {
task.await.log_err();
}
}
}
this.update(cx, |workspace, _| {
workspace.active_branch_session = next_key;
workspace._branch_session_task = None;
})?;
anyhow::Ok(())
}));
}
fn capture_center_pane_group(
pane_group: &Member,
window: &mut Window,
cx: &mut App,
) -> SerializedPaneGroup {
match pane_group {
Member::Axis(PaneAxis {
axis,
members,
flexes,
bounding_boxes: _,
}) => SerializedPaneGroup::Group {
axis: SerializedAxis(*axis),
children: members
.iter()
.map(|member| Self::capture_center_pane_group(member, window, cx))
.collect::<Vec<_>>(),
flexes: Some(flexes.lock().clone()),
},
Member::Pane(pane_handle) => {
let (items, active, pinned_count) = {
let pane = pane_handle.read(cx);
let active_item_id = pane.active_item().map(|item| item.item_id());
(
pane.items()
.filter_map(|handle| {
let handle = handle.to_serializable_item_handle(cx)?;
Some(SerializedItem {
kind: Arc::from(handle.serialized_item_kind()),
item_id: handle.item_id().as_u64(),
active: Some(handle.item_id()) == active_item_id,
preview: pane.is_active_preview_item(handle.item_id()),
})
})
.collect::<Vec<_>>(),
pane.has_focus(window, cx),
pane.pinned_count(),
)
};
SerializedPaneGroup::Pane(SerializedPane::new(items, active, pinned_count))
}
}
}
fn save_branch_workspace_layout(
&mut self,
branch_key: BranchSessionKey,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
let Some(workspace_id) = self.database_id else {
return Task::ready(Ok(()));
};
let mut serialize_item_tasks = Vec::new();
let items = self
.items(cx)
.map(|item| item.boxed_clone())
.collect::<Vec<_>>();
for item in items {
if let Some(task) = item
.to_serializable_item_handle(cx)
.and_then(|item| item.serialize(self, false, window, cx))
{
serialize_item_tasks.push(task);
}
}
let center_group = Self::capture_center_pane_group(&self.center.root, window, cx);
let db = WorkspaceDb::global(cx);
cx.spawn(async move |_, _| {
for task in serialize_item_tasks {
task.await.log_err();
}
db.save_branch_workspace_layout(
workspace_id,
branch_key.repository_path,
branch_key.branch_name,
center_group,
)
.await
})
}
fn restore_branch_workspace_layout(
&mut self,
center_group: SerializedPaneGroup,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
let Some(workspace_id) = self.database_id else {
return Task::ready(Ok(()));
};
self.restoring_branch_session = true;
cx.spawn_in(window, async move |workspace, cx| {
if let Some(close_task) = workspace.update_in(cx, |workspace, window, cx| {
workspace.close_all_internal(false, SaveIntent::Skip, true, window, cx)
})? {
close_task.await.log_err();
}
let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?;
let restored = center_group
.deserialize(&project, workspace_id, workspace.clone(), cx)
.await;
workspace.update_in(cx, |workspace, window, cx| {
if let Some((center_group, active_pane, _)) = restored {
workspace.remove_panes(workspace.center.root.clone(), window, cx);
workspace.center = PaneGroup::with_root(center_group);
workspace.center.set_is_center(true);
workspace.center.mark_positions(cx);
if let Some(active_pane) = active_pane {
workspace.set_active_pane(&active_pane, window, cx);
cx.focus_self(window);
} else {
workspace.set_active_pane(&workspace.center.first_pane(), window, cx);
}
}
workspace.restoring_branch_session = false;
workspace.serialize_workspace_internal(window, cx).detach();
cx.notify();
})?;
Ok(())
})
}
fn remove_panes(&mut self, member: Member, window: &mut Window, cx: &mut Context<Workspace>) {
match member {
Member::Axis(PaneAxis { members, .. }) => {
@ -7063,6 +7326,7 @@ impl Workspace {
pub(crate) fn load_workspace(
serialized_workspace: SerializedWorkspace,
paths_to_open: Vec<Option<ProjectPath>>,
cleanup_unloaded_items: bool,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> Task<Result<Vec<Option<Box<dyn ItemHandle>>>>> {
@ -7170,23 +7434,25 @@ impl Workspace {
// the database filling up, we delete items that haven't been loaded now.
//
// The items that have been loaded, have been saved after they've been added to the workspace.
let clean_up_tasks = workspace.update_in(cx, |_, window, cx| {
item_ids_by_kind
.into_iter()
.map(|(item_kind, loaded_items)| {
SerializableItemRegistry::cleanup(
item_kind,
serialized_workspace.id,
loaded_items,
window,
cx,
)
.log_err()
})
.collect::<Vec<_>>()
})?;
if cleanup_unloaded_items {
let clean_up_tasks = workspace.update_in(cx, |_, window, cx| {
item_ids_by_kind
.into_iter()
.map(|(item_kind, loaded_items)| {
SerializableItemRegistry::cleanup(
item_kind,
serialized_workspace.id,
loaded_items,
window,
cx,
)
.log_err()
})
.collect::<Vec<_>>()
})?;
futures::future::join_all(clean_up_tasks).await;
futures::future::join_all(clean_up_tasks).await;
}
workspace
.update_in(cx, |workspace, window, cx| {
@ -8214,6 +8480,7 @@ fn open_items(
.map(|(_, project_path)| project_path)
.cloned()
.collect(),
true,
window,
cx,
)

View file

@ -22,6 +22,7 @@ pub struct WorkspaceSettings {
pub restore_on_startup: settings::RestoreOnStartupBehavior,
pub cli_default_open_behavior: settings::CliDefaultOpenBehavior,
pub restore_on_file_reopen: bool,
pub restore_tabs_per_branch: bool,
pub drop_target_size: f32,
pub use_system_path_prompts: bool,
pub use_system_prompts: bool,
@ -102,6 +103,7 @@ impl Settings for WorkspaceSettings {
restore_on_startup: workspace.restore_on_startup.unwrap(),
cli_default_open_behavior: workspace.cli_default_open_behavior.unwrap(),
restore_on_file_reopen: workspace.restore_on_file_reopen.unwrap(),
restore_tabs_per_branch: workspace.restore_tabs_per_branch.unwrap(),
drop_target_size: workspace.drop_target_size.unwrap(),
use_system_path_prompts: workspace.use_system_path_prompts.unwrap(),
use_system_prompts: workspace.use_system_prompts.unwrap(),

View file

@ -3487,6 +3487,16 @@ List of strings containing any combination of:
`boolean` values
## Restore Tabs Per Branch
- Description: Whether to restore open tabs and pane layout per active git branch.
- Setting: `restore_tabs_per_branch`
- Default: `true`
**Options**
`boolean` values
## Restore on Startup
- Description: Controls session restoration on startup.