Skip to content

Commit 4bf1b7f

Browse files
committed
feat(commands): context-menu operations, registry commands, Help menu; fixes
File context menu gains Copy / Move / Pack to Archive; the pane menu gains Paste and Refresh, and "Open in Default App" is relabelled Reveal in File Manager. directory_properties and open_config_file become registry commands (palette, bindable, newt cmd). macOS gets a Help menu with Documentation. Palette disambiguation: "Back (History)…" / "Forward (History)…", distinct short names for send_to_terminal and focus_terminal, focus commands moved to the Terminal category. Fixes riding along: saving or deleting a connection refuses to proceed when connections.toml exists but fails to parse, instead of treating it as empty and writing back a one-entry list; the preferences watcher compares resolved bindings and commands by value, so an in-place edit of a [[bind]] entry reloads; the file server validates a remote object's Content-Type before using it as a header; the drag ghost is built from DOM nodes rather than innerHTML.
1 parent a0296ca commit 4bf1b7f

9 files changed

Lines changed: 120 additions & 24 deletions

File tree

‎FEATURE_DUMP.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Zoom is applied via the webview zoom factor and persisted app-wide in the runtim
7373
- **Close Window** (Mod+Shift+W): Closes the current window. Shifted (gnome-terminal convention) so a session window can't be closed on a stray Mod+W. Viewer/editor children can't outlive the session they were spawned from, so closing a main window takes them along: a Rust-side `CloseRequested` interceptor (covers the command, the menu item, and the OS close button alike) closes viewer children outright and sweeps editor children — dirty ones prompt, the window close resumes once the last child editor is gone, and refusing a prompt aborts the close.
7474
- **Quit Newt** (Mod+Q): Closes all main windows, exiting the app. Open editor windows are swept first so their unsaved-changes prompts fire: the quit waits (event-driven, via the `Destroyed` handler) for the last editor to close before the main windows go, and refusing any editor's prompt aborts the quit (`cancel_quit`), leaving all remaining windows open. Pre-warmed hidden editors are exempt from the sweep. OS-initiated termination (macOS Dock quit, logout) goes through the same sweep via an `applicationShouldTerminate:` method injected into tao's app delegate at runtime (`terminate_guard` in `main.rs` — tao doesn't implement the selector and `RunEvent::ExitRequested` never fires for OS termination, see tauri#9198): a dirty editor cancels the termination and runs the sweep, a clean app terminates immediately so it never blocks logout. Editor dirty state is mirrored to Rust for this via `set_editor_dirty`. The `ExitRequested { code: None }` handler remains as a safety net for the all-windows-closed path.
7575
- **Reload Window**: Available in the Debug dialog (debug builds only).
76-
- **Refresh File List** (Mod+R): Force-refreshes the active pane's directory listing.
76+
- **Refresh File List** (unbound by default — `mod+r` would be Quick Connect's Ctrl+R on Windows/Linux; palette, pane context menu): Force-refreshes the active pane's directory listing.
7777

7878
Multiple main windows coexist in the same process: "New Window", remote connections, and elevated sessions all create additional windows within the running app. Each window has its own independent session, panes, terminals, and operations; closing the last main window exits the app.
7979

@@ -85,6 +85,7 @@ Main windows carry a minimal native menu on macOS (Windows/Linux main windows ha
8585
- **File**: New Window, Connect to Remote Host…, Close Window.
8686
- **Edit**: predefined Cut/Copy/Paste/Select All (required so macOS routes Cmd+C/V/X/A to the webview — previously the sole content of the main-window menu).
8787
- **Window**: predefined Minimize, Zoom.
88+
- **Help**: Newt Documentation (`documentation`).
8889

8990
Viewer and editor windows prepend an app submenu with Quit Newt (Cmd+Q) on macOS, so quit works whichever window is focused.
9091

@@ -276,6 +277,7 @@ The default browser context menu is suppressed in the main window (but not in th
276277
| View | F3 |
277278
| Edit | F4 |
278279
| Copy Path | Mod+C |
280+
| Copy / Move / Pack to Archive | F5 / F6 / Alt+F5 |
279281
| Rename | F2 |
280282
| Delete | F8 |
281283
| Delete Permanently | Shift+Delete |
@@ -289,10 +291,12 @@ The Follow row appears only when it would do something: labelled "Reveal Source"
289291

290292
| Item | Shortcut |
291293
|------|----------|
292-
| Open in Default App | Shift+F3 (host-local VFS only) |
294+
| Reveal in File Manager | Shift+F3 (host-local VFS only) |
293295
| New Directory | F7 |
294296
| New File | |
295-
| Directory Properties | |
297+
| Paste | Mod+V |
298+
| Refresh | (unbound) |
299+
| Directory Properties | (`directory_properties`, unbound) |
296300
| Windows Menu | Shift+RClick (Windows host + host-local files only) |
297301

298302
**Windows shell context menu** (Windows host, host-local files): the trailing "Windows Menu" item — or Shift+right-click to skip our menu entirely — pops the classic `IContextMenu` shell menu (not the Windows 11 abbreviated one) for the effective selection; on empty space (or the `..` row) it targets the current directory itself. Implementation (`main_window/shell_menu.rs`, `shell_context_menu` command): runs synchronously on the main thread (`TrackPopupMenuEx` pumps its own modal loop), `SHParseDisplayName` on de-verbatimed paths (`launch_cwd`) → parent `IShellFolder::GetUIObjectOf` → `QueryContextMenu` → `TrackPopupMenuEx(TPM_RETURNCMD)` → `InvokeCommand`, with the Tauri window temporarily subclassed to forward `WM_INITMENUPOPUP`/`WM_DRAWITEM`/`WM_MEASUREITEM`/`WM_MENUCHAR` to `IContextMenu2/3` so dynamic submenus ("Open with", "Send to") populate. User-cancelled verbs (`ERROR_CANCELLED`) are not errors; shell-side mutations (delete, rename, …) reach the pane through the directory watcher. In **elevated sessions** the menu is still built and invoked by the non-elevated UI process — items the desktop user can't read (e.g. `C:\Windows\System32\config\*`) fail at `SHParseDisplayName` with an error, and any verb that did run would run non-elevated. Deliberate: an agent-side menu (feasible for the UAC transport, which shares the interactive desktop) would invoke *every* verb elevated — "Open"/"Open with" silently spawning admin-token processes — which is why Explorer refuses to run elevated too. Power users who need shell verbs on admin-only files can just run `newt.exe` itself elevated.

‎src-tauri/src/cmd/mod.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,7 @@ async fn dispatch_registry_command_inner(
482482
"edit" => window::cmd_edit(ctx, pane).await?,
483483
"rename" => dialog::cmd_rename(ctx, pane)?,
484484
"properties" => dialog::cmd_properties(ctx, pane)?,
485+
"directory_properties" => dialog::cmd_directory_properties(ctx, pane)?,
485486
"delete_selected" => operations::cmd_delete_selected(ctx, pane).await?,
486487
"delete_permanent" => operations::cmd_delete_permanent(ctx, pane).await?,
487488
"create_directory" => dialog::cmd_create_directory(ctx, pane)?,
@@ -525,6 +526,7 @@ async fn dispatch_registry_command_inner(
525526
"next_terminal" => terminal::cmd_next_terminal(ctx, pane)?,
526527
"prev_terminal" => terminal::cmd_prev_terminal(ctx, pane)?,
527528
"open_settings" => dialog::cmd_open_settings(ctx, pane)?,
529+
"open_config_file" => preferences::cmd_open_config_file(ctx, pane)?,
528530
"command_palette" => dialog::cmd_command_palette(ctx, pane)?,
529531
"hot_paths" => dialog::cmd_hot_paths(ctx, pane)?,
530532
"user_commands" => dialog::cmd_user_commands(ctx, pane)?,
@@ -579,6 +581,7 @@ mod dispatch_tests {
579581
"edit",
580582
"rename",
581583
"properties",
584+
"directory_properties",
582585
"delete_selected",
583586
"delete_permanent",
584587
"create_directory",
@@ -621,6 +624,7 @@ mod dispatch_tests {
621624
"next_terminal",
622625
"prev_terminal",
623626
"open_settings",
627+
"open_config_file",
624628
"command_palette",
625629
"hot_paths",
626630
"user_commands",

‎src-tauri/src/connections.rs‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,13 +205,23 @@ pub fn list_connections(config_dir: &std::path::Path) -> Vec<ConnectionProfile>
205205
}
206206
}
207207

208+
/// An absent file is an empty list; an unparsable one is an error, since
209+
/// the caller is about to write the list back.
210+
fn read_connections_file(path: &std::path::Path) -> Result<ConnectionsFile, Error> {
211+
let content = match std::fs::read_to_string(path) {
212+
Ok(content) => content,
213+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Default::default()),
214+
Err(e) => return Err(Error::Custom(format!("{}: {e}", path.display()))),
215+
};
216+
toml::from_str(&content).map_err(|e| Error::Custom(format!("{}: {e}", path.display())))
217+
}
218+
208219
pub fn save_connection(
209220
config_dir: &std::path::Path,
210221
profile: ConnectionProfile,
211222
) -> Result<(), Error> {
212223
let path = connections_path(config_dir);
213-
let content = std::fs::read_to_string(&path).unwrap_or_default();
214-
let mut file: ConnectionsFile = toml::from_str(&content).unwrap_or_default();
224+
let mut file = read_connections_file(&path)?;
215225

216226
// Update existing or append
217227
if let Some(existing) = file.connections.iter_mut().find(|c| c.id == profile.id) {
@@ -227,8 +237,7 @@ pub fn save_connection(
227237

228238
pub fn delete_connection(config_dir: &std::path::Path, id: &str) -> Result<(), Error> {
229239
let path = connections_path(config_dir);
230-
let content = std::fs::read_to_string(&path).unwrap_or_default();
231-
let mut file: ConnectionsFile = toml::from_str(&content).unwrap_or_default();
240+
let mut file = read_connections_file(&path)?;
232241

233242
file.connections.retain(|c| c.id != id);
234243

‎src-tauri/src/file_server.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,11 @@ async fn serve_file(
139139
}
140140
};
141141

142+
// The type may come verbatim from a remote object's Content-Type.
142143
let mime = details
143144
.mime_type
144-
.unwrap_or_else(|| "application/octet-stream".to_string());
145+
.and_then(|m| header::HeaderValue::from_str(&m).ok())
146+
.unwrap_or(header::HeaderValue::from_static("application/octet-stream"));
145147
let file_size = details.size;
146148

147149
let range_header = headers

‎src-tauri/src/main_window/menu.rs‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,22 @@ fn build(app_handle: &AppHandle, label: &str) -> Result<Menu<Wry>, Error> {
114114
],
115115
)?;
116116

117+
let help_submenu = Submenu::with_items(
118+
app_handle,
119+
"Help",
120+
true,
121+
&[&cmd_item("documentation", "Newt Documentation")?],
122+
)?;
123+
117124
Ok(Menu::with_items(
118125
app_handle,
119-
&[&app_submenu, &file_submenu, &edit_submenu, &window_submenu],
126+
&[
127+
&app_submenu,
128+
&file_submenu,
129+
&edit_submenu,
130+
&window_submenu,
131+
&help_submenu,
132+
],
120133
)?)
121134
}
122135

‎src-tauri/src/preferences/commands.rs‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,16 @@ pub fn default_commands() -> Vec<CommandDef> {
211211
needs_pane: true,
212212
scope: CommandScope::Main,
213213
},
214+
CommandDef {
215+
id: "directory_properties".into(),
216+
name: "Directory Properties...".into(),
217+
short_name: None,
218+
category: "File".into(),
219+
default_keys: vec![],
220+
default_when: Some("pane_focused".into()),
221+
needs_pane: true,
222+
scope: CommandScope::Main,
223+
},
214224
CommandDef {
215225
id: "delete_selected".into(),
216226
name: "Delete Selected".into(),
@@ -330,7 +340,7 @@ pub fn default_commands() -> Vec<CommandDef> {
330340
},
331341
CommandDef {
332342
id: "history_back".into(),
333-
name: "Back...".into(),
343+
name: "Back (History)...".into(),
334344
short_name: None,
335345
category: "Navigation".into(),
336346
default_keys: vec!["alt+left".into()],
@@ -340,7 +350,7 @@ pub fn default_commands() -> Vec<CommandDef> {
340350
},
341351
CommandDef {
342352
id: "history_forward".into(),
343-
name: "Forward...".into(),
353+
name: "Forward (History)...".into(),
344354
short_name: None,
345355
category: "Navigation".into(),
346356
default_keys: vec!["alt+right".into()],
@@ -361,7 +371,7 @@ pub fn default_commands() -> Vec<CommandDef> {
361371
CommandDef {
362372
id: "send_to_terminal".into(),
363373
name: "Open in Terminal".into(),
364-
short_name: Some("Terminal".into()),
374+
short_name: Some("Open in Term".into()),
365375
category: "Terminal".into(),
366376
default_keys: vec!["mod+enter".into()],
367377
default_when: Some("pane_focused".into()),
@@ -675,7 +685,7 @@ pub fn default_commands() -> Vec<CommandDef> {
675685
id: "focus_panes".into(),
676686
name: "Focus File Panes".into(),
677687
short_name: Some("Panes".into()),
678-
category: "Navigation".into(),
688+
category: "Terminal".into(),
679689
default_keys: vec!["alt+up".into()],
680690
default_when: None,
681691
needs_pane: false,
@@ -684,8 +694,8 @@ pub fn default_commands() -> Vec<CommandDef> {
684694
CommandDef {
685695
id: "focus_terminal".into(),
686696
name: "Focus Terminal".into(),
687-
short_name: Some("Terminal".into()),
688-
category: "Navigation".into(),
697+
short_name: Some("Focus Term".into()),
698+
category: "Terminal".into(),
689699
default_keys: vec!["alt+down".into()],
690700
default_when: None,
691701
needs_pane: false,
@@ -731,6 +741,16 @@ pub fn default_commands() -> Vec<CommandDef> {
731741
needs_pane: false,
732742
scope: CommandScope::Main,
733743
},
744+
CommandDef {
745+
id: "open_config_file".into(),
746+
name: "Open Settings File in Editor".into(),
747+
short_name: None,
748+
category: "File".into(),
749+
default_keys: vec![],
750+
default_when: None,
751+
needs_pane: false,
752+
scope: CommandScope::Main,
753+
},
734754
CommandDef {
735755
id: "command_palette".into(),
736756
name: "Command Palette...".into(),

‎src-tauri/src/preferences/mod.rs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,15 @@ pub struct BookmarkAdded {
4848
}
4949

5050
/// A resolved keybinding after `mod+` expansion and cascading.
51-
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
51+
#[derive(Debug, Clone, PartialEq, serde::Serialize, specta::Type)]
5252
pub struct ResolvedBinding {
5353
pub key: String,
5454
pub command: String,
5555
pub when: Option<String>,
5656
}
5757

5858
/// Command metadata for the command palette.
59-
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
59+
#[derive(Debug, Clone, PartialEq, serde::Serialize, specta::Type)]
6060
pub struct CommandInfo {
6161
pub id: String,
6262
pub name: String,
@@ -629,10 +629,10 @@ impl PreferencesManager {
629629
{
630630
let mut guard = resolved.write();
631631
if guard.settings != new_resolved.settings
632-
|| guard.bindings.len() != new_resolved.bindings.len()
632+
|| guard.bindings != new_resolved.bindings
633633
|| guard.bookmarks != new_resolved.bookmarks
634634
|| guard.user_commands != new_resolved.user_commands
635-
|| guard.commands.len() != new_resolved.commands.len()
635+
|| guard.commands != new_resolved.commands
636636
{
637637
*guard = new_resolved.clone();
638638
} else {

‎src/main_window/ContextMenu.tsx‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,33 @@ export function FileContextMenuContent({
116116

117117
<CM.Separator className={styles.separator} />
118118

119+
<CM.Item
120+
className={styles.item}
121+
disabled={isParentDir}
122+
onSelect={() => safe(ipc.cmdCopy(paneHandle))}
123+
>
124+
Copy
125+
<Shortcut commands={commands} id="copy" />
126+
</CM.Item>
127+
<CM.Item
128+
className={styles.item}
129+
disabled={isParentDir}
130+
onSelect={() => safe(ipc.cmdMove(paneHandle))}
131+
>
132+
Move
133+
<Shortcut commands={commands} id="move" />
134+
</CM.Item>
135+
<CM.Item
136+
className={styles.item}
137+
disabled={isParentDir}
138+
onSelect={() => safe(ipc.cmdCreateArchive(paneHandle))}
139+
>
140+
Pack to Archive
141+
<Shortcut commands={commands} id="create_archive" />
142+
</CM.Item>
143+
144+
<CM.Separator className={styles.separator} />
145+
119146
<CM.Item
120147
className={styles.item}
121148
disabled={isParentDir}
@@ -193,7 +220,7 @@ export function PaneContextMenuContent({
193220
className={styles.item}
194221
onSelect={() => safe(ipc.cmdOpenFolder(paneHandle))}
195222
>
196-
Open in Default App
223+
Reveal in File Manager
197224
<Shortcut commands={commands} id="open_folder" />
198225
</CM.Item>
199226
<CM.Separator className={styles.separator} />
@@ -214,6 +241,20 @@ export function PaneContextMenuContent({
214241
New File
215242
<Shortcut commands={commands} id="create_file" />
216243
</CM.Item>
244+
<CM.Item
245+
className={styles.item}
246+
onSelect={() => safe(ipc.cmdPasteFromClipboard(paneHandle))}
247+
>
248+
Paste
249+
<Shortcut commands={commands} id="paste_from_clipboard" />
250+
</CM.Item>
251+
<CM.Item
252+
className={styles.item}
253+
onSelect={() => safe(ipc.cmdRefresh(paneHandle))}
254+
>
255+
Refresh
256+
<Shortcut commands={commands} id="refresh" />
257+
</CM.Item>
217258

218259
<CM.Separator className={styles.separator} />
219260

@@ -222,6 +263,7 @@ export function PaneContextMenuContent({
222263
onSelect={() => safe(ipc.cmdDirectoryProperties(paneHandle))}
223264
>
224265
Directory Properties
266+
<Shortcut commands={commands} id="directory_properties" />
225267
</CM.Item>
226268

227269
<ShellMenuItem onShellMenu={onShellMenu} />

‎src/main_window/Pane.tsx‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1219,12 +1219,14 @@ function PaneInner(
12191219
if (ghost) {
12201220
if (dnd.files.length === 1) {
12211221
const f = dnd.files[0];
1222-
if (f.is_dir) {
1223-
ghost.innerHTML = `<div class="file-icon folder"></div> ${f.name}`;
1224-
} else {
1222+
const icon = document.createElement("div");
1223+
icon.className = f.is_dir ? "file-icon folder" : "file-icon";
1224+
if (!f.is_dir) {
12251225
const { ch, color } = getFileIconChar(f.name, f.is_dir);
1226-
ghost.innerHTML = `<div class="file-icon" style="color: ${color}">${ch}</div> ${f.name}`;
1226+
icon.style.color = color;
1227+
icon.textContent = ch;
12271228
}
1229+
ghost.replaceChildren(icon, ` ${f.name}`);
12281230
} else {
12291231
ghost.textContent = `${dnd.files.length} items`;
12301232
}

0 commit comments

Comments
 (0)