Skip to content

Commit 791a29a

Browse files
committed
Add per-pane tab support
- Independent tab bar per pane (always visible, Chrome-style shrinking, max 10) - ⌘T creates instant new tab (clone trick — no reload), ⌘W closes, ⌃Tab/⌃⇧Tab cycles - {#key} destroy/recreate for clean tab switches, cursor restored by filename - Native context menu: pin/unpin, close others (skips pinned), close with confirmation for pinned - Top menu too - Persistence with migration from old scalar keys, debounced saveAppStatus - MCP: activate_tab and pin_tab tools, tab list in cmdr://state YAML, debounced frontend→backend sync - Rust: menu items replace "Close Window", tab context menu popup, update_pane_tabs command Collateral: - Sort is per-tab now (removed global columnSortOrders) - Confirmation dialog now cancels on ESC
1 parent 2b17ab5 commit 791a29a

31 files changed

Lines changed: 2835 additions & 247 deletions

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,9 @@ There are two MCP servers available to you:
143143
- When getting oriented, consider the docs: `docs` folder and `CLAUDE.md` files in each directory.
144144
- When coming up with a plan for a development, save it to `docs/specs/{feature}-plan.md` in this repo (we clean out old
145145
plans every few weeks/months, git history remembers them).
146+
- When writing a plan, always capture the INTENTION behind the plan, not just the steps. That way, the implementing
147+
agent or human will know the "why"s behind the decisions and can adapt dynamically if it makes an unexpected discovery
148+
during implementation.
146149
- Also create an accompanying task list that fully covers but doesn't duplicate the plan on a high level.
147150
If all items on the task list are honestly marked as done, the plan is fully implemented in great quality.
148151
Tasks should be one-liners, grouped by milestones. Include docs, testing, and running all necessary checks.

apps/desktop/coverage-allowlist.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,12 @@
162162
},
163163
"indexing/index-state.svelte.ts": {
164164
"reason": "Reactive Svelte state, depends on Tauri event listeners"
165+
},
166+
"file-explorer/tabs/TabBar.svelte": {
167+
"reason": "UI component, tab interactions tested via DualPaneExplorer integration"
168+
},
169+
"tauri-commands/tab.ts": {
170+
"reason": "Tauri command wrappers, tested via integration"
165171
}
166172
}
167173
}

apps/desktop/src-tauri/capabilities/default.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"fs:allow-temp-write",
3535
"fs:allow-remove",
3636
"updater:default",
37-
"process:allow-restart"
37+
"process:allow-restart",
38+
"dialog:allow-ask"
3839
]
3940
}

apps/desktop/src-tauri/src/commands/file_system.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ use crate::file_system::write_operations::{
99
use crate::file_system::{
1010
DirectorySortMode, FileEntry, ListingStartResult, ListingStats, OperationStatus, OperationSummary, ResortResult,
1111
ScanConflict, SortColumn, SortOrder, StreamingListingStartResult, VolumeCopyConfig, VolumeCopyScanResult,
12-
WriteOperationConfig, WriteOperationError, WriteOperationStartResult, cancel_listing as ops_cancel_listing,
13-
cancel_all_write_operations as ops_cancel_all_write_operations,
12+
WriteOperationConfig, WriteOperationError, WriteOperationStartResult,
13+
cancel_all_write_operations as ops_cancel_all_write_operations, cancel_listing as ops_cancel_listing,
1414
cancel_write_operation as ops_cancel_write_operation, copy_between_volumes as ops_copy_between_volumes,
1515
copy_files_start as ops_copy_files_start, delete_files_start as ops_delete_files_start,
1616
find_file_index as ops_find_file_index, get_file_at as ops_get_file_at, get_file_range as ops_get_file_range,

apps/desktop/src-tauri/src/commands/ui.rs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::ignore_poison::IgnorePoison;
2-
use crate::menu::{MenuState, build_context_menu};
2+
use crate::menu::{MenuState, build_context_menu, build_tab_context_menu};
33
#[cfg(target_os = "macos")]
44
use std::process::Command;
55
use tauri::menu::ContextMenu;
@@ -175,6 +175,40 @@ pub fn open_in_editor(_path: String) -> Result<(), String> {
175175
Err("Open in editor is only available on macOS".to_string())
176176
}
177177

178+
/// Shows a native context menu for a tab (fire-and-forget).
179+
/// The selected action is delivered asynchronously via a `tab-context-action` Tauri event
180+
/// from `on_menu_event`, because `popup()` returns before the event loop processes the
181+
/// `MenuEvent` from muda. A synchronous channel approach doesn't work here — the wakeup
182+
/// signal posted during the popup's NSEvent tracking loop gets consumed, so `recv` always
183+
/// times out.
184+
#[tauri::command]
185+
pub fn show_tab_context_menu(
186+
window: Window<tauri::Wry>,
187+
is_pinned: bool,
188+
can_close: bool,
189+
has_other_unpinned_tabs: bool,
190+
) -> Result<(), String> {
191+
let app = window.app_handle().clone();
192+
193+
let menu =
194+
build_tab_context_menu(&app, is_pinned, can_close, has_other_unpinned_tabs).map_err(|e| e.to_string())?;
195+
menu.popup(window).map_err(|e| e.to_string())?;
196+
197+
Ok(())
198+
}
199+
200+
/// Updates the File menu "Pin tab" / "Unpin tab" label based on the active tab's pin state.
201+
#[tauri::command]
202+
pub fn update_pin_tab_menu<R: Runtime>(app: AppHandle<R>, is_pinned: bool) -> Result<(), String> {
203+
let menu_state = app.state::<MenuState<R>>();
204+
let guard = menu_state.pin_tab.lock_ignore_poison();
205+
let Some(item) = guard.as_ref() else {
206+
return Err("Menu not initialized".to_string());
207+
};
208+
let label = if is_pinned { "Unpin tab" } else { "Pin tab" };
209+
item.set_text(label).map_err(|e| e.to_string())
210+
}
211+
178212
/// Executes a menu action for the current context.
179213
pub fn execute_menu_action<R: Runtime>(app: &AppHandle<R>, id: &str) {
180214
let state = app.state::<MenuState<R>>();

apps/desktop/src-tauri/src/lib.rs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,10 @@ mod volumes;
8282
mod stubs;
8383

8484
use menu::{
85-
ABOUT_ID, COMMAND_PALETTE_ID, ENTER_LICENSE_KEY_ID, GO_BACK_ID, GO_FORWARD_ID, GO_PARENT_ID, MenuState, RENAME_ID,
86-
SETTINGS_ID, SHOW_HIDDEN_FILES_ID, SORT_ASCENDING_ID, SORT_BY_CREATED_ID, SORT_BY_EXTENSION_ID,
87-
SORT_BY_MODIFIED_ID, SORT_BY_NAME_ID, SORT_BY_SIZE_ID, SORT_DESCENDING_ID, SWAP_PANES_ID, SWITCH_PANE_ID,
85+
ABOUT_ID, CLOSE_TAB_ID, COMMAND_PALETTE_ID, ENTER_LICENSE_KEY_ID, GO_BACK_ID, GO_FORWARD_ID, GO_PARENT_ID,
86+
MenuState, NEW_TAB_ID, PIN_TAB_MENU_ID, RENAME_ID, SETTINGS_ID, SHOW_HIDDEN_FILES_ID, SORT_ASCENDING_ID,
87+
SORT_BY_CREATED_ID, SORT_BY_EXTENSION_ID, SORT_BY_MODIFIED_ID, SORT_BY_NAME_ID, SORT_BY_SIZE_ID,
88+
SORT_DESCENDING_ID, SWAP_PANES_ID, SWITCH_PANE_ID, TAB_CLOSE_ID, TAB_CLOSE_OTHERS_ID, TAB_PIN_ID,
8889
VIEW_MODE_BRIEF_ID, VIEW_MODE_FULL_ID, VIEWER_WORD_WRAP_ID, ViewMode,
8990
};
9091
use tauri::{Emitter, Manager};
@@ -254,6 +255,7 @@ pub fn run() {
254255
*menu_state.view_submenu.lock_ignore_poison() = Some(menu_items.view_submenu);
255256
*menu_state.view_mode_full_position.lock_ignore_poison() = menu_items.view_mode_full_position;
256257
*menu_state.view_mode_brief_position.lock_ignore_poison() = menu_items.view_mode_brief_position;
258+
*menu_state.pin_tab.lock_ignore_poison() = Some(menu_items.pin_tab);
257259
app.manage(menu_state);
258260

259261
// Set window title based on license status
@@ -380,6 +382,26 @@ pub fn run() {
380382
} else if id == SWAP_PANES_ID {
381383
// Emit event to swap panes (main window only)
382384
let _ = app.emit_to("main", "swap-panes", ());
385+
} else if id == NEW_TAB_ID {
386+
// Emit event to create a new tab (main window only)
387+
let _ = app.emit_to("main", "new-tab", ());
388+
} else if id == CLOSE_TAB_ID {
389+
// Close the active tab if main window is focused, otherwise close the focused window
390+
if let Some(main_window) = app.get_webview_window("main")
391+
&& main_window.is_focused().unwrap_or(false)
392+
{
393+
let _ = app.emit_to("main", "close-tab", ());
394+
} else {
395+
for (_label, window) in app.webview_windows() {
396+
if window.is_focused().unwrap_or(false) {
397+
let _ = window.close();
398+
break;
399+
}
400+
}
401+
}
402+
} else if id == PIN_TAB_MENU_ID {
403+
// Emit event to toggle pin on the active tab (main window only)
404+
let _ = app.emit_to("main", "toggle-pin-tab", ());
383405
} else if id == SORT_BY_NAME_ID
384406
|| id == SORT_BY_EXTENSION_ID
385407
|| id == SORT_BY_SIZE_ID
@@ -417,6 +439,9 @@ pub fn run() {
417439
break;
418440
}
419441
}
442+
} else if id == TAB_PIN_ID || id == TAB_CLOSE_OTHERS_ID || id == TAB_CLOSE_ID {
443+
// Tab context menu: emit event to frontend (async — popup returns before this fires)
444+
let _ = app.emit_to("main", "tab-context-action", serde_json::json!({ "action": id }));
420445
} else {
421446
// Handle file actions
422447
commands::ui::execute_menu_action(app, id);
@@ -476,6 +501,8 @@ pub fn run() {
476501
commands::icons::clear_extension_icon_cache,
477502
commands::icons::clear_directory_icon_cache,
478503
commands::ui::show_file_context_menu,
504+
commands::ui::show_tab_context_menu,
505+
commands::ui::update_pin_tab_menu,
479506
commands::ui::show_main_window,
480507
commands::ui::update_menu_context,
481508
commands::ui::toggle_hidden_files,
@@ -488,6 +515,7 @@ pub fn run() {
488515
mcp::pane_state::update_left_pane_state,
489516
mcp::pane_state::update_right_pane_state,
490517
mcp::pane_state::update_focused_pane,
518+
mcp::pane_state::update_pane_tabs,
491519
mcp::dialog_state::notify_dialog_opened,
492520
mcp::dialog_state::notify_dialog_closed,
493521
mcp::dialog_state::register_known_dialogs,

apps/desktop/src-tauri/src/mcp/CLAUDE.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,12 @@ Expose Cmdr functionality to AI agents via the Model Context Protocol (MCP). Age
2121

2222
### Tools (`tools.rs`)
2323

24-
19 semantic tools grouped by category:
24+
20 semantic tools grouped by category:
2525
- Navigation (6): `select_volume`, `nav_to_path`, `move_cursor`, etc.
2626
- Cursor/Selection (3): `move_cursor`, `open_under_cursor`, `select`
2727
- File operations (3): `copy`, `mkdir`, `refresh`
2828
- View (3): `sort`, `toggle_hidden`, `set_view_mode`
29+
- Tabs (2): `activate_tab` (switch to a specific tab by pane + tab ID), `pin_tab` (pin/unpin a tab)
2930
- Dialogs (1): `dialog` (unified open/focus/close)
3031
- App (3): `switch_pane`, `swap_panes`, `quit`
3132

@@ -48,17 +49,17 @@ Constants and configuration for the MCP server (port, bind address, transport se
4849

4950
### State stores
5051

51-
- `PaneStateStore`: Current state of left/right panes (path, files, cursor, selection)
52+
- `PaneStateStore`: Current state of left/right panes (path, files, cursor, selection, tabs)
5253
- `SoftDialogTracker`: Which dialogs MCP thinks are open (in `dialog_state.rs`)
5354
- `SettingsStateStore`: Current settings window state (section, settings, shortcuts)
5455

55-
Frontend syncs state to these stores via Tauri commands (`update_left_pane_state`, `mcp_update_settings_sections`, etc.).
56+
Frontend syncs state to these stores via Tauri commands (`update_left_pane_state`, `update_pane_tabs`, `mcp_update_settings_sections`, etc.).
5657

5758
## Key decisions
5859

5960
### Why agent-centric API?
6061

61-
The original design mirrored keyboard shortcuts (43 tools like `nav_up`, `nav_down`). This forced agents to make dozens of calls to find a file. The agent-centric redesign (Jan 2026) consolidated to 19 semantic tools (`move_cursor(index=42)`, `nav_to_path("/Users")`). This reduced round-trips from 6+ reads to 1 (`cmdr://state` resource).
62+
The original design mirrored keyboard shortcuts (43 tools like `nav_up`, `nav_down`). This forced agents to make dozens of calls to find a file. The agent-centric redesign (Jan 2026) consolidated to 20 semantic tools (`move_cursor(index=42)`, `nav_to_path("/Users")`). This reduced round-trips from 6+ reads to 1 (`cmdr://state` resource).
6263

6364
### Why YAML over JSON for resources?
6465

@@ -132,6 +133,10 @@ This separation keeps main window overhead minimal.
132133

133134
`INVALID_PARAMS = -32602`, `INTERNAL_ERROR = -32603`, etc. These are defined by the JSON-RPC spec, not MCP. Don't change them.
134135

136+
### Tab state is synced separately from pane state
137+
138+
Tab info (id, path, pinned, active) is synced to `PaneState.tabs` via a separate `update_pane_tabs` command, debounced at ~100ms in the frontend. The `cmdr://state` resource shows a `tabs:` section per pane only when tabs are synced (non-empty). The `activate_tab` tool emits an `mcp-activate-tab` Tauri event that the frontend handles by switching to the specified tab.
139+
135140
### Schema version doesn't apply to MCP state
136141

137142
MCP state stores don't have `_schemaVersion` fields. They're runtime-only, not persisted. If the state format changes, just restart the app.

apps/desktop/src-tauri/src/mcp/executor.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ pub fn execute_tool<R: Runtime>(app: &AppHandle<R>, name: &str, params: &Value)
6161
"select_volume" | "nav_to_path" | "move_cursor" | "scroll_to" => {
6262
execute_nav_command_with_params(app, name, params)
6363
}
64+
// Tab commands
65+
"activate_tab" => execute_activate_tab(app, params),
66+
"pin_tab" => execute_pin_tab(app, params),
6467
// File operation commands
6568
"copy" => execute_copy(app),
6669
"mkdir" => execute_mkdir(app),
@@ -105,6 +108,85 @@ fn execute_swap_panes<R: Runtime>(app: &AppHandle<R>) -> ToolResult {
105108
Ok(json!("OK: Swapped left and right panes"))
106109
}
107110

111+
/// Execute activate_tab command.
112+
fn execute_activate_tab<R: Runtime>(app: &AppHandle<R>, params: &Value) -> ToolResult {
113+
let pane = params
114+
.get("pane")
115+
.and_then(|v| v.as_str())
116+
.ok_or_else(|| ToolError::invalid_params("Missing 'pane' parameter"))?;
117+
let tab_id = params
118+
.get("tab_id")
119+
.and_then(|v| v.as_str())
120+
.ok_or_else(|| ToolError::invalid_params("Missing 'tab_id' parameter"))?;
121+
122+
if !["left", "right"].contains(&pane) {
123+
return Err(ToolError::invalid_params("pane must be 'left' or 'right'"));
124+
}
125+
126+
// Validate that the tab ID exists in the pane's synced tab list
127+
if let Some(store) = app.try_state::<PaneStateStore>() {
128+
let pane_state = match pane {
129+
"left" => store.get_left(),
130+
"right" => store.get_right(),
131+
_ => unreachable!(),
132+
};
133+
if !pane_state.tabs.is_empty() && !pane_state.tabs.iter().any(|t| t.id == tab_id) {
134+
let available_ids: Vec<&str> = pane_state.tabs.iter().map(|t| t.id.as_str()).collect();
135+
return Err(ToolError::invalid_params(format!(
136+
"Tab '{}' not found in {} pane. Available tabs: {}",
137+
tab_id,
138+
pane,
139+
available_ids.join(", ")
140+
)));
141+
}
142+
}
143+
144+
app.emit("mcp-activate-tab", json!({"pane": pane, "tabId": tab_id}))?;
145+
Ok(json!(format!("OK: Switched to tab {} in {} pane", tab_id, pane)))
146+
}
147+
148+
/// Execute pin_tab command.
149+
fn execute_pin_tab<R: Runtime>(app: &AppHandle<R>, params: &Value) -> ToolResult {
150+
let pane = params
151+
.get("pane")
152+
.and_then(|v| v.as_str())
153+
.ok_or_else(|| ToolError::invalid_params("Missing 'pane' parameter"))?;
154+
let tab_id = params
155+
.get("tab_id")
156+
.and_then(|v| v.as_str())
157+
.ok_or_else(|| ToolError::invalid_params("Missing 'tab_id' parameter"))?;
158+
let pinned = params
159+
.get("pinned")
160+
.and_then(|v| v.as_bool())
161+
.ok_or_else(|| ToolError::invalid_params("Missing 'pinned' parameter (boolean)"))?;
162+
163+
if !["left", "right"].contains(&pane) {
164+
return Err(ToolError::invalid_params("pane must be 'left' or 'right'"));
165+
}
166+
167+
// Validate that the tab ID exists in the pane's synced tab list
168+
if let Some(store) = app.try_state::<PaneStateStore>() {
169+
let pane_state = match pane {
170+
"left" => store.get_left(),
171+
"right" => store.get_right(),
172+
_ => unreachable!(),
173+
};
174+
if !pane_state.tabs.is_empty() && !pane_state.tabs.iter().any(|t| t.id == tab_id) {
175+
let available_ids: Vec<&str> = pane_state.tabs.iter().map(|t| t.id.as_str()).collect();
176+
return Err(ToolError::invalid_params(format!(
177+
"Tab '{}' not found in {} pane. Available tabs: {}",
178+
tab_id,
179+
pane,
180+
available_ids.join(", ")
181+
)));
182+
}
183+
}
184+
185+
let action = if pinned { "Pinned" } else { "Unpinned" };
186+
app.emit("mcp-pin-tab", json!({"pane": pane, "tabId": tab_id, "pinned": pinned}))?;
187+
Ok(json!(format!("OK: {} tab {} in {} pane", action, tab_id, pane)))
188+
}
189+
108190
/// Execute toggle_hidden command.
109191
fn execute_toggle_hidden<R: Runtime>(app: &AppHandle<R>) -> ToolResult {
110192
let result = toggle_hidden_files(app.clone()).map_err(ToolError::internal)?;

apps/desktop/src-tauri/src/mcp/pane_state.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ use serde::{Deserialize, Serialize};
66
use std::sync::RwLock;
77
use tauri::{AppHandle, Manager};
88

9+
/// Represents a tab in a pane (for MCP state reporting).
10+
#[derive(Debug, Clone, Serialize, Deserialize)]
11+
#[serde(rename_all = "camelCase")]
12+
pub struct TabInfo {
13+
pub id: String,
14+
pub path: String,
15+
pub pinned: bool,
16+
pub active: bool,
17+
}
18+
919
/// Represents a file entry in a pane.
1020
#[derive(Debug, Clone, Serialize, Deserialize)]
1121
#[serde(rename_all = "camelCase")]
@@ -46,6 +56,8 @@ pub struct PaneState {
4656
pub loaded_end: usize,
4757
#[serde(default)]
4858
pub show_hidden: bool,
59+
#[serde(default)]
60+
pub tabs: Vec<TabInfo>,
4961
}
5062

5163
/// Shared state for both panes.
@@ -91,17 +103,25 @@ impl PaneStateStore {
91103
}
92104

93105
/// Tauri command to update left pane state from frontend.
106+
/// Preserves `tabs` — those are synced separately via `update_pane_tabs`.
94107
#[tauri::command]
95108
pub fn update_left_pane_state(app: AppHandle, state: PaneState) {
96109
if let Some(store) = app.try_state::<PaneStateStore>() {
110+
let tabs = store.left.read().unwrap().tabs.clone();
111+
let mut state = state;
112+
state.tabs = tabs;
97113
store.set_left(state);
98114
}
99115
}
100116

101117
/// Tauri command to update right pane state from frontend.
118+
/// Preserves `tabs` — those are synced separately via `update_pane_tabs`.
102119
#[tauri::command]
103120
pub fn update_right_pane_state(app: AppHandle, state: PaneState) {
104121
if let Some(store) = app.try_state::<PaneStateStore>() {
122+
let tabs = store.right.read().unwrap().tabs.clone();
123+
let mut state = state;
124+
state.tabs = tabs;
105125
store.set_right(state);
106126
}
107127
}
@@ -114,6 +134,19 @@ pub fn update_focused_pane(app: AppHandle, pane: String) {
114134
}
115135
}
116136

137+
/// Tauri command to update tab list for a pane from frontend (for MCP state reporting).
138+
#[tauri::command]
139+
pub fn update_pane_tabs(app: AppHandle, pane: String, tabs: Vec<TabInfo>) {
140+
if let Some(store) = app.try_state::<PaneStateStore>() {
141+
let pane_state = match pane.as_str() {
142+
"left" => &store.left,
143+
"right" => &store.right,
144+
_ => return,
145+
};
146+
pane_state.write().unwrap().tabs = tabs;
147+
}
148+
}
149+
117150
#[cfg(test)]
118151
mod tests {
119152
use super::*;
@@ -142,6 +175,7 @@ mod tests {
142175
loaded_start: 0,
143176
loaded_end: 1,
144177
show_hidden: false,
178+
tabs: vec![],
145179
};
146180

147181
store.set_left(state.clone());

0 commit comments

Comments
 (0)