Skip to content

Commit 4d44cda

Browse files
committed
Add file selection feature
- Space toggles selection at cursor - Shift+arrows/click for range selection with shrink support - Cmd+A / Cmd+Shift+A for select/deselect all - Selection preserved on sort, cleared on navigation - Yellow highlight for selected files - MCP tools: selection_clear, selection_selectAll, etc.
1 parent e4ea55c commit 4d44cda

22 files changed

Lines changed: 1026 additions & 88 deletions

apps/desktop/scripts/check-type-drift.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,10 @@ function typesAreCompatible(expected: string, actual: string): boolean {
545545
function rustVariantToTsValue(variantName: string, renameAll?: string): string {
546546
if (renameAll === 'snake_case') {
547547
// PascalCase to snake_case
548-
return variantName.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '')
548+
return variantName
549+
.replace(/([A-Z])/g, '_$1')
550+
.toLowerCase()
551+
.replace(/^_/, '')
549552
}
550553
if (renameAll === 'camelCase') {
551554
// PascalCase to camelCase

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,20 +113,26 @@ pub fn cancel_listing(listing_id: String) {
113113
/// * `sort_order` - Ascending or descending.
114114
/// * `cursor_filename` - Optional filename to track; returns its new index after sorting.
115115
/// * `include_hidden` - Whether to include hidden files when calculating cursor index.
116+
/// * `selected_indices` - Optional indices of selected files to track through re-sort.
117+
/// * `all_selected` - If true, all files are selected (optimization).
116118
#[tauri::command]
117119
pub fn resort_listing(
118120
listing_id: String,
119121
sort_by: SortColumn,
120122
sort_order: SortOrder,
121123
cursor_filename: Option<String>,
122124
include_hidden: bool,
125+
selected_indices: Option<Vec<usize>>,
126+
all_selected: Option<bool>,
123127
) -> Result<ResortResult, String> {
124128
ops_resort_listing(
125129
&listing_id,
126130
sort_by,
127131
sort_order,
128132
cursor_filename.as_deref(),
129133
include_hidden,
134+
selected_indices.as_deref(),
135+
all_selected.unwrap_or(false),
130136
)
131137
}
132138

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

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,9 @@ pub struct ResortResult {
773773
/// New index of the file that was at the cursor position before re-sorting.
774774
/// None if the filename wasn't provided or wasn't found.
775775
pub new_cursor_index: Option<usize>,
776+
/// New indices of previously selected files after re-sorting.
777+
/// None if no selected_indices were provided.
778+
pub new_selected_indices: Option<Vec<usize>>,
776779
}
777780

778781
/// Re-sorts an existing cached listing in-place.
@@ -785,22 +788,44 @@ pub struct ResortResult {
785788
/// * `sort_order` - Ascending or descending
786789
/// * `cursor_filename` - Optional filename to track; returns its new index after sorting
787790
/// * `include_hidden` - Whether to include hidden files when calculating cursor index
791+
/// * `selected_indices` - Optional indices of selected files to track through re-sort
792+
/// * `all_selected` - If true, all files are selected (optimization to avoid passing huge arrays)
788793
///
789794
/// # Returns
790-
/// A `ResortResult` with the new cursor index (if filename was provided and found).
795+
/// A `ResortResult` with the new cursor index and new selected indices.
791796
pub fn resort_listing(
792797
listing_id: &str,
793798
sort_by: SortColumn,
794799
sort_order: SortOrder,
795800
cursor_filename: Option<&str>,
796801
include_hidden: bool,
802+
selected_indices: Option<&[usize]>,
803+
all_selected: bool,
797804
) -> Result<ResortResult, String> {
798805
let mut cache = LISTING_CACHE.write().map_err(|_| "Failed to acquire cache lock")?;
799806

800807
let listing = cache
801808
.get_mut(listing_id)
802809
.ok_or_else(|| format!("Listing not found: {}", listing_id))?;
803810

811+
// Collect filenames of selected files before re-sorting
812+
let selected_filenames: Option<Vec<String>> = if all_selected {
813+
// All files selected - we'll rebuild the full set after sort
814+
None
815+
} else {
816+
selected_indices.map(|indices| {
817+
let entries_for_index = if include_hidden {
818+
listing.entries.iter().collect::<Vec<_>>()
819+
} else {
820+
listing.entries.iter().filter(|e| !e.name.starts_with('.')).collect()
821+
};
822+
indices
823+
.iter()
824+
.filter_map(|&idx| entries_for_index.get(idx).map(|e| e.name.clone()))
825+
.collect()
826+
})
827+
};
828+
804829
// Re-sort the entries
805830
sort_entries(&mut listing.entries, sort_by, sort_order);
806831
listing.sort_by = sort_by;
@@ -819,7 +844,33 @@ pub fn resort_listing(
819844
}
820845
});
821846

822-
Ok(ResortResult { new_cursor_index })
847+
// Find new indices of selected files
848+
let new_selected_indices = if all_selected {
849+
// All files are still selected after re-sort
850+
let count = if include_hidden {
851+
listing.entries.len()
852+
} else {
853+
listing.entries.iter().filter(|e| !e.name.starts_with('.')).count()
854+
};
855+
Some((0..count).collect())
856+
} else {
857+
selected_filenames.map(|filenames| {
858+
let entries_for_lookup: Vec<_> = if include_hidden {
859+
listing.entries.iter().collect()
860+
} else {
861+
listing.entries.iter().filter(|e| !e.name.starts_with('.')).collect()
862+
};
863+
filenames
864+
.iter()
865+
.filter_map(|name| entries_for_lookup.iter().position(|e| e.name == *name))
866+
.collect()
867+
})
868+
};
869+
870+
Ok(ResortResult {
871+
new_cursor_index,
872+
new_selected_indices,
873+
})
823874
}
824875

825876
// ============================================================================

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ pub fn execute_tool<R: Runtime>(app: &AppHandle<R>, name: &str, params: &Value)
5555
n if n.starts_with("file_") => execute_file_command(app, n),
5656
// Volume commands
5757
n if n.starts_with("volume_") => execute_volume_command(app, n, params),
58+
// Selection commands
59+
n if n.starts_with("selection_") => execute_selection_command(app, n, params),
5860
// Context commands
5961
n if n.starts_with("context_") => execute_context_command(app, n),
6062
_ => Err(ToolError::invalid_params(format!("Unknown tool: {name}"))),
@@ -229,6 +231,51 @@ fn execute_volume_command<R: Runtime>(app: &AppHandle<R>, name: &str, params: &V
229231
}
230232
}
231233

234+
/// Execute a selection command.
235+
/// These emit events to the frontend to manipulate file selection.
236+
fn execute_selection_command<R: Runtime>(app: &AppHandle<R>, name: &str, params: &Value) -> ToolResult {
237+
match name {
238+
"selection_clear" => {
239+
app.emit("mcp-selection", json!({"action": "clear"}))
240+
.map_err(|e| ToolError::internal(e.to_string()))?;
241+
Ok(json!({"success": true, "action": "clear"}))
242+
}
243+
"selection_selectAll" => {
244+
app.emit("mcp-selection", json!({"action": "selectAll"}))
245+
.map_err(|e| ToolError::internal(e.to_string()))?;
246+
Ok(json!({"success": true, "action": "selectAll"}))
247+
}
248+
"selection_deselectAll" => {
249+
app.emit("mcp-selection", json!({"action": "deselectAll"}))
250+
.map_err(|e| ToolError::internal(e.to_string()))?;
251+
Ok(json!({"success": true, "action": "deselectAll"}))
252+
}
253+
"selection_toggleAtCursor" => {
254+
app.emit("mcp-selection", json!({"action": "toggleAtCursor"}))
255+
.map_err(|e| ToolError::internal(e.to_string()))?;
256+
Ok(json!({"success": true, "action": "toggleAtCursor"}))
257+
}
258+
"selection_selectRange" => {
259+
let start_index = params
260+
.get("startIndex")
261+
.and_then(|v| v.as_i64())
262+
.ok_or_else(|| ToolError::invalid_params("Missing 'startIndex' parameter"))?;
263+
let end_index = params
264+
.get("endIndex")
265+
.and_then(|v| v.as_i64())
266+
.ok_or_else(|| ToolError::invalid_params("Missing 'endIndex' parameter"))?;
267+
268+
app.emit(
269+
"mcp-selection",
270+
json!({"action": "selectRange", "startIndex": start_index, "endIndex": end_index}),
271+
)
272+
.map_err(|e| ToolError::internal(e.to_string()))?;
273+
Ok(json!({"success": true, "action": "selectRange", "startIndex": start_index, "endIndex": end_index}))
274+
}
275+
_ => Err(ToolError::invalid_params(format!("Unknown selection command: {name}"))),
276+
}
277+
}
278+
232279
/// Execute a context command.
233280
fn execute_context_command<R: Runtime>(app: &AppHandle<R>, name: &str) -> ToolResult {
234281
let store = app

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ pub struct PaneState {
3434
pub cursor_index: usize,
3535
/// View mode (brief or full)
3636
pub view_mode: String,
37+
/// Indices of selected files
38+
#[serde(default)]
39+
pub selected_indices: Vec<usize>,
3740
}
3841

3942
/// Shared state for both panes.
@@ -122,6 +125,7 @@ mod tests {
122125
}],
123126
cursor_index: 0,
124127
view_mode: "brief".to_string(),
128+
selected_indices: vec![],
125129
};
126130

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

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

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@ pub fn get_all_resources() -> Vec<Resource> {
8282
description: "List of available volumes (favorites, main volume, attached volumes, cloud drives). Each volume has an index for use with volume_selectLeft/volume_selectRight tools.".to_string(),
8383
mime_type: "application/json".to_string(),
8484
},
85+
Resource {
86+
uri: "cmdr://selection".to_string(),
87+
name: "Selected files".to_string(),
88+
description: "List of selected file indices in the focused pane".to_string(),
89+
mime_type: "application/json".to_string(),
90+
},
8591
]
8692
}
8793

@@ -149,6 +155,22 @@ pub fn read_resource<R: Runtime>(app: &tauri::AppHandle<R>, uri: &str) -> Result
149155
(json!({ "cursor": file_under_cursor }), "application/json")
150156
}
151157
"cmdr://status" => (json!({ "status": "ok", "app": "cmdr" }), "application/json"),
158+
"cmdr://selection" => {
159+
let focused = store.get_focused_pane();
160+
let state = if focused == "left" {
161+
store.get_left()
162+
} else {
163+
store.get_right()
164+
};
165+
(
166+
json!({
167+
"pane": focused,
168+
"selectedIndices": state.selected_indices,
169+
"count": state.selected_indices.len()
170+
}),
171+
"application/json",
172+
)
173+
}
152174
#[cfg(target_os = "macos")]
153175
"cmdr://volumes" => {
154176
let locations = volumes::list_locations();
@@ -186,9 +208,9 @@ mod tests {
186208
fn test_resource_count() {
187209
let resources = get_all_resources();
188210
#[cfg(target_os = "macos")]
189-
assert_eq!(resources.len(), 8);
211+
assert_eq!(resources.len(), 9); // 8 base + 1 selection
190212
#[cfg(not(target_os = "macos"))]
191-
assert_eq!(resources.len(), 7);
213+
assert_eq!(resources.len(), 8); // 7 base + 1 selection
192214
}
193215

194216
#[test]

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,12 @@ fn test_tool_input_schemas_are_valid() {
105105
#[test]
106106
fn test_total_tool_count() {
107107
let tools = get_all_tools();
108-
// 3 app + 3 view + 1 pane + 12 nav + 8 sort + 5 file + 2 volume = 34
108+
// 3 app + 3 view + 1 pane + 12 nav + 8 sort + 5 file + 2 volume + 5 selection = 39
109109
// (context tools and volume_list moved to resources)
110110
assert_eq!(
111111
tools.len(),
112-
34,
113-
"Expected 34 tools, got {}. Did you add/remove tools?",
112+
39,
113+
"Expected 39 tools, got {}. Did you add/remove tools?",
114114
tools.len()
115115
);
116116
}
@@ -133,9 +133,9 @@ fn test_no_duplicate_tool_names() {
133133
fn test_resource_count() {
134134
let resources = get_all_resources();
135135
#[cfg(target_os = "macos")]
136-
assert_eq!(resources.len(), 8, "Expected 8 resources");
136+
assert_eq!(resources.len(), 9, "Expected 9 resources");
137137
#[cfg(not(target_os = "macos"))]
138-
assert_eq!(resources.len(), 7, "Expected 7 resources");
138+
assert_eq!(resources.len(), 8, "Expected 8 resources");
139139
}
140140

141141
#[test]
@@ -542,6 +542,7 @@ fn test_pane_state_store_update_left() {
542542
}],
543543
cursor_index: 0,
544544
view_mode: "brief".to_string(),
545+
selected_indices: vec![],
545546
};
546547

547548
store.set_left(state.clone());
@@ -593,6 +594,7 @@ fn test_pane_state_cursor_index_bounds() {
593594
}],
594595
cursor_index: 999, // Out of bounds
595596
view_mode: "brief".to_string(),
597+
selected_indices: vec![],
596598
};
597599

598600
store.set_left(state);
@@ -711,6 +713,7 @@ fn test_empty_file_list() {
711713
files: vec![],
712714
cursor_index: 0,
713715
view_mode: "brief".to_string(),
716+
selected_indices: vec![],
714717
};
715718

716719
let json = serde_json::to_value(&state).unwrap();
@@ -736,6 +739,7 @@ fn test_large_file_count() {
736739
files,
737740
cursor_index: 500,
738741
view_mode: "full".to_string(),
742+
selected_indices: vec![1, 5, 10], // Some selected files
739743
};
740744

741745
// Should serialize reasonably fast

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

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,37 @@ fn get_volume_tools() -> Vec<Tool> {
135135
]
136136
}
137137

138+
/// Get selection tools.
139+
fn get_selection_tools() -> Vec<Tool> {
140+
vec![
141+
Tool::no_params("selection_clear", "Clear all selected files in the focused pane"),
142+
Tool::no_params("selection_selectAll", "Select all files in the focused pane"),
143+
Tool::no_params("selection_deselectAll", "Deselect all files in the focused pane"),
144+
Tool::no_params(
145+
"selection_toggleAtCursor",
146+
"Toggle selection of the file under the cursor",
147+
),
148+
Tool {
149+
name: "selection_selectRange".to_string(),
150+
description: "Select a range of files by index".to_string(),
151+
input_schema: json!({
152+
"type": "object",
153+
"properties": {
154+
"startIndex": {
155+
"type": "integer",
156+
"description": "Start index (inclusive)"
157+
},
158+
"endIndex": {
159+
"type": "integer",
160+
"description": "End index (inclusive)"
161+
}
162+
},
163+
"required": ["startIndex", "endIndex"]
164+
}),
165+
},
166+
]
167+
}
168+
138169
/// Get all available tools.
139170
pub fn get_all_tools() -> Vec<Tool> {
140171
let mut tools = Vec::new();
@@ -145,6 +176,7 @@ pub fn get_all_tools() -> Vec<Tool> {
145176
tools.extend(get_sort_tools());
146177
tools.extend(get_file_tools());
147178
tools.extend(get_volume_tools());
179+
tools.extend(get_selection_tools());
148180
tools
149181
}
150182

@@ -173,9 +205,15 @@ mod tests {
173205
#[test]
174206
fn test_all_tools_count() {
175207
let tools = get_all_tools();
176-
// 3 app + 3 view + 1 pane + 12 nav + 8 sort + 5 file + 2 volume = 34
208+
// 3 app + 3 view + 1 pane + 12 nav + 8 sort + 5 file + 2 volume + 5 selection = 39
177209
// (context tools and volume_list moved to resources)
178-
assert_eq!(tools.len(), 34);
210+
assert_eq!(tools.len(), 39);
211+
}
212+
213+
#[test]
214+
fn test_selection_tools_count() {
215+
let tools = get_selection_tools();
216+
assert_eq!(tools.len(), 5);
179217
}
180218

181219
#[test]

0 commit comments

Comments
 (0)