diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index faaac51..572eaf8 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "desktop", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "desktop", - "version": "0.2.1", + "version": "0.2.2", "license": "MIT", "dependencies": { "@novnc/novnc": "^1.5.0", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b362ea6..0493fd2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "desktop", - "version": "0.2.1", + "version": "0.2.2", "description": "Cross-platform SSH manager desktop app", "scripts": { "dev": "vite", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 1abeaf6..5103bf7 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "src-tauri" -version = "0.2.1" +version = "0.2.2" edition = "2024" license = "MIT" build = "build.rs" diff --git a/apps/desktop/src-tauri/src/plugins/file_workspace.rs b/apps/desktop/src-tauri/src/plugins/file_workspace.rs index 583214a..c7a07a9 100644 --- a/apps/desktop/src-tauri/src/plugins/file_workspace.rs +++ b/apps/desktop/src-tauri/src/plugins/file_workspace.rs @@ -2,14 +2,14 @@ use super::{HostEnrichContext, NssPlugin, PluginCapability, PluginManifest}; use crate::ssh_config::HostConfig; use anyhow::Result; -pub const FILE_WORKSPACE_PLUGIN_ID: &str = "dev.nosuckshell.plugin.file-workspace"; +pub const NSS_COMMANDER_PLUGIN_ID: &str = "dev.nosuckshell.plugin.nss-commander"; pub struct FileWorkspacePlugin; impl NssPlugin for FileWorkspacePlugin { fn manifest(&self) -> PluginManifest { PluginManifest { - id: FILE_WORKSPACE_PLUGIN_ID.to_string(), + id: NSS_COMMANDER_PLUGIN_ID.to_string(), version: env!("CARGO_PKG_VERSION").to_string(), display_name: "NSS-Commander".to_string(), capabilities: vec![PluginCapability::SettingsUi], diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index b8d2cef..bdbf496 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "NoSuckShell", - "version": "0.2.1", + "version": "0.2.2", "identifier": "dev.nosuckshell.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 7c74969..981d28a 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -75,7 +75,6 @@ import { type FrameModePreset, type HelpAboutSubTab, type IdentityStoreSubTab, - type IntegrationsSubTab, type InterfaceSubTab, type LayoutMode, type ListTonePreset, @@ -93,7 +92,7 @@ const LayoutCommandCenter = lazy(async () => { }); import { LAYOUT_PRESET_DEFINITIONS } from "./layoutPresets"; -import { FILE_WORKSPACE_PLUGIN_ID, PROXMUX_PLUGIN_ID } from "./features/builtin-plugin-ids"; +import { NSS_COMMANDER_PLUGIN_ID, PROXMUX_PLUGIN_ID } from "./features/builtin-plugin-ids"; import { type ContextActionId, type PaneContextSessionKind } from "./features/context-actions"; import { FILE_DND_PAYLOAD_MIME, @@ -101,6 +100,7 @@ import { type FileDragPayload, } from "./features/file-pane-dnd"; import { setFileTransferClipboardFromEvent } from "./features/file-transfer-clipboard"; +import { runFilePaneTransfer } from "./features/file-pane-transfer"; import { buildQuickConnectUserCandidates, parseHostPortInput, @@ -271,6 +271,7 @@ import { cloneWorkspaceSnapshot, compactSplitSlotsByPaneOrder, createEmptyWorkspaceSnapshot, + createNssCommanderWorkspaceSnapshot, DEFAULT_WORKSPACE_ID, findFirstFreePaneInOrder, type WorkspaceSnapshot, @@ -423,7 +424,6 @@ export function App() { const [activeAppSettingsTab, setActiveAppSettingsTab] = useState("connection"); const [connectionSubTab, setConnectionSubTab] = useState("hosts"); const [workspaceSubTab, setWorkspaceSubTab] = useState("views"); - const [integrationsSubTab, setIntegrationsSubTab] = useState("proxmux"); const [interfaceSubTab, setInterfaceSubTab] = useState("appearance"); const [helpAboutSubTab, setHelpAboutSubTab] = useState("help"); const [identityStoreSubTab, setIdentityStoreSubTab] = useState("overview"); @@ -461,7 +461,7 @@ export function App() { const [splitSlots, setSplitSlots] = useState>(() => createInitialPaneState()); /** Per SSH/local session: file browser vs terminal (keyed by session id so layout changes do not remap views). */ const [sessionFileViews, setSessionFileViews] = useState>({}); - /** Gated by built-in plugin `dev.nosuckshell.plugin.file-workspace` (Settings → Plugins & license). */ + /** Gated by built-in plugin `dev.nosuckshell.plugin.nss-commander` (Settings → Plugins). */ const [fileWorkspacePluginEnabled, setFileWorkspacePluginEnabled] = useState(true); const [proxmuxSidebarAvailable, setProxmuxSidebarAvailable] = useState(false); const [proxmuxResourceCount, setProxmuxResourceCount] = useState(0); @@ -693,6 +693,7 @@ export function App() { const orphanClosingSessionIdsRef = useRef>(new Set()); const lastInternalDragPayloadRef = useRef(null); const localFilePanePathsRef = useRef>({}); + const remoteFilePanePathsRef = useRef>({}); const filePaneTitlesRef = useRef>({}); const [filePaneTitleEpoch, setFilePaneTitleEpoch] = useState(0); const sessionTerminalCwdRef = useRef>({}); @@ -855,7 +856,7 @@ export function App() { const refreshLicensedPlugins = useCallback(async () => { try { const rows = await listPlugins(); - const fw = rows.find((r) => r.manifest.id === FILE_WORKSPACE_PLUGIN_ID); + const fw = rows.find((r) => r.manifest.id === NSS_COMMANDER_PLUGIN_ID); setFileWorkspacePluginEnabled(fw ? fw.enabled && fw.entitlementOk : true); const px = rows.find((r) => r.manifest.id === PROXMUX_PLUGIN_ID); setProxmuxSidebarAvailable(Boolean(px && px.enabled && px.entitlementOk)); @@ -974,12 +975,48 @@ export function App() { const isStackedShell = layoutMode === "compact" || (layoutMode === "auto" && viewportStacked); const verticalStackScrollEnabled = useMemo( - () => - Boolean(workspaceSnapshots[activeWorkspaceId]?.preferVerticalNewPanes) && - !(isStackedShell && mobileShellTab === "terminal"), + () => { + const activeWorkspace = workspaceSnapshots[activeWorkspaceId]; + if (!activeWorkspace) { + return false; + } + // NSS-Commander workspaces stay side-by-side by design. + if (activeWorkspace.kind === "nss-commander") { + return false; + } + return Boolean(activeWorkspace.preferVerticalNewPanes) && !(isStackedShell && mobileShellTab === "terminal"); + }, [activeWorkspaceId, isStackedShell, mobileShellTab, workspaceSnapshots], ); + useEffect(() => { + const activeWorkspace = workspaceSnapshots[activeWorkspaceId]; + if (!activeWorkspace || activeWorkspace.kind !== "nss-commander") { + return; + } + if (splitTree.type !== "split" || splitTree.axis === "horizontal") { + return; + } + setSplitTree((prev) => (prev.type === "split" ? { ...prev, axis: "horizontal" } : prev)); + setWorkspaceSnapshots((prev) => { + const current = prev[activeWorkspaceId]; + if (!current || current.kind !== "nss-commander" || current.splitTree.type !== "split") { + return prev; + } + if (current.splitTree.axis === "horizontal") { + return prev; + } + return { + ...prev, + [activeWorkspaceId]: { + ...current, + splitTree: { ...cloneSplitTree(current.splitTree), axis: "horizontal" }, + preferVerticalNewPanes: false, + }, + }; + }); + }, [activeWorkspaceId, splitTree, workspaceSnapshots]); + const availableTags = useMemo(() => { const tagSet = new Set(); for (const metadata of Object.values(metadataStore.hosts)) { @@ -1332,6 +1369,7 @@ export function App() { setSplitTree, setActivePaneIndex, setActiveSession, + setSessionFileViews, }); const storeUsers = useMemo(() => Object.values(entityStore.users), [entityStore.users]); @@ -3908,10 +3946,22 @@ export function App() { }; const applyWorkspaceSnapshot = useCallback((snapshot: WorkspaceSnapshot) => { isApplyingWorkspaceSnapshotRef.current = true; - setSessionFileViews({}); + const nextSplitTree = cloneSplitTree(snapshot.splitTree); + if (snapshot.kind === "nss-commander") { + if (nextSplitTree.type === "split" && nextSplitTree.axis !== "horizontal") { + nextSplitTree.axis = "horizontal"; + } + const nextFileViews: Record = {}; + for (const sid of snapshot.splitSlots) { + if (sid) nextFileViews[sid] = "local"; + } + setSessionFileViews(nextFileViews); + } else { + setSessionFileViews({}); + } setSplitSlots([...snapshot.splitSlots]); setPaneLayouts(clonePaneLayouts(snapshot.paneLayouts)); - setSplitTree(cloneSplitTree(snapshot.splitTree)); + setSplitTree(nextSplitTree); setActivePaneIndex(snapshot.activePaneIndex); setActiveSession(snapshot.activeSessionId); queueMicrotask(() => { @@ -3926,6 +3976,7 @@ export function App() { const currentSnapshot: WorkspaceSnapshot = { id: activeWorkspaceId, name: workspaceSnapshots[activeWorkspaceId]?.name ?? activeWorkspaceId, + kind: workspaceSnapshots[activeWorkspaceId]?.kind, preferVerticalNewPanes: workspaceSnapshots[activeWorkspaceId]?.preferVerticalNewPanes === true, splitSlots: [...splitSlots], paneLayouts: clonePaneLayouts(paneLayouts), @@ -3966,6 +4017,37 @@ export function App() { setActiveWorkspaceId(workspaceId); applyWorkspaceSnapshot(nextSnapshot); }, [activePaneIndex, activeSession, activeWorkspaceId, applyWorkspaceSnapshot, paneLayouts, splitSlots, splitTree, workspaceOrder.length]); + const createNssCommanderWorkspace = useCallback(async () => { + setError(""); + try { + const left = await startLocalSession(); + const right = await startLocalSession(); + setSessions((prev) => [ + ...prev, + { id: left.session_id, kind: "local", label: "Local (left)" }, + { id: right.session_id, kind: "local", label: "Local (right)" }, + ]); + const workspaceId = `workspace-nss-${createId()}`; + const nextSnapshot = createNssCommanderWorkspaceSnapshot(workspaceId, "NSS-Commander", left.session_id, right.session_id); + setWorkspaceSnapshots((prev) => ({ + ...prev, + [workspaceId]: nextSnapshot, + [activeWorkspaceId]: { + ...(prev[activeWorkspaceId] ?? createEmptyWorkspaceSnapshot(activeWorkspaceId, "Main")), + splitSlots: [...splitSlots], + paneLayouts: clonePaneLayouts(paneLayouts), + splitTree: cloneSplitTree(splitTree), + activePaneIndex, + activeSessionId: activeSession, + }, + })); + setWorkspaceOrder((prev) => [...prev, workspaceId]); + setActiveWorkspaceId(workspaceId); + applyWorkspaceSnapshot(nextSnapshot); + } catch (e) { + setError(String(e)); + } + }, [activePaneIndex, activeSession, activeWorkspaceId, applyWorkspaceSnapshot, paneLayouts, splitSlots, splitTree]); const removeWorkspace = useCallback( (workspaceId: string) => { if (workspaceOrder.length <= 1) { @@ -4016,7 +4098,7 @@ export function App() { const setWorkspaceVerticalStacking = useCallback((workspaceId: string, enabled: boolean) => { setWorkspaceSnapshots((prev) => { const snapshot = prev[workspaceId]; - if (!snapshot || snapshot.preferVerticalNewPanes === enabled) { + if (!snapshot || snapshot.kind === "nss-commander" || snapshot.preferVerticalNewPanes === enabled) { return prev; } return { @@ -4272,7 +4354,6 @@ export function App() { case "pane.toggleRemoteFiles": { if (!fileWorkspacePluginEnabled) { setActiveAppSettingsTab("integrations"); - setIntegrationsSubTab("plugins"); setIsAppSettingsOpen(true); break; } @@ -4305,7 +4386,6 @@ export function App() { case "pane.toggleLocalFiles": { if (!fileWorkspacePluginEnabled) { setActiveAppSettingsTab("integrations"); - setIntegrationsSubTab("plugins"); setIsAppSettingsOpen(true); break; } @@ -5013,6 +5093,222 @@ export function App() { localFilePanePathsRef.current[paneIndex] = pathKey; }, []); + const onRemoteFilePanePathChange = useCallback((paneIndex: number, path: string) => { + remoteFilePanePathsRef.current[paneIndex] = path; + }, []); + + const onLocalFilePaneF5Copy = useCallback( + async (sourcePaneIndex: number, sourcePath: string, names: string[]) => { + if (names.length === 0) return; + const otherPaneIndex = paneOrder.find((idx) => { + if (idx === sourcePaneIndex) return false; + const view = sessionFileViews[splitSlots[idx] ?? ""]; + return view === "local" || view === "remote"; + }); + if (otherPaneIndex === undefined) return; + const destView = sessionFileViews[splitSlots[otherPaneIndex] ?? ""]; + setError(""); + try { + for (const name of names) { + if (destView === "remote") { + const destSpec = remoteSshSpecForPane(otherPaneIndex); + if (!destSpec) { + continue; + } + const destPath = remoteFilePanePathsRef.current[otherPaneIndex] ?? "."; + await runFilePaneTransfer( + { kind: "local", pathKey: sourcePath, name }, + { kind: "remote", spec: destSpec, parentPath: destPath }, + ); + } else { + const destPath = localFilePanePathsRef.current[otherPaneIndex] ?? ""; + await runFilePaneTransfer({ kind: "local", pathKey: sourcePath, name }, { kind: "local", pathKey: destPath }); + } + } + } catch (e) { + setError(String(e)); + } + }, + [paneOrder, remoteSshSpecForPane, sessionFileViews, splitSlots], + ); + + const onRemoteFilePaneF5Copy = useCallback( + async (sourcePaneIndex: number, sourcePath: string, names: string[]) => { + if (names.length === 0) { + return; + } + const sourceSpec = remoteSshSpecForPane(sourcePaneIndex); + if (!sourceSpec) { + return; + } + const otherPaneIndex = paneOrder.find((idx) => { + if (idx === sourcePaneIndex) return false; + const view = sessionFileViews[splitSlots[idx] ?? ""]; + return view === "local" || view === "remote"; + }); + if (otherPaneIndex === undefined) { + return; + } + const destView = sessionFileViews[splitSlots[otherPaneIndex] ?? ""]; + setError(""); + try { + for (const name of names) { + if (destView === "remote") { + const destSpec = remoteSshSpecForPane(otherPaneIndex); + if (!destSpec) { + continue; + } + const destPath = remoteFilePanePathsRef.current[otherPaneIndex] ?? "."; + await runFilePaneTransfer( + { kind: "remote", spec: sourceSpec, parentPath: sourcePath, name }, + { kind: "remote", spec: destSpec, parentPath: destPath }, + ); + } else { + const destPath = localFilePanePathsRef.current[otherPaneIndex] ?? ""; + await runFilePaneTransfer( + { kind: "remote", spec: sourceSpec, parentPath: sourcePath, name }, + { kind: "local", pathKey: destPath }, + ); + } + } + } catch (e) { + setError(String(e)); + } + }, + [paneOrder, remoteSshSpecForPane, sessionFileViews, splitSlots], + ); + + const onLocalFilePaneTabSwitch = useCallback( + (sourcePaneIndex: number) => { + const otherPaneIndex = paneOrder.find((idx) => { + if (idx === sourcePaneIndex) return false; + const view = sessionFileViews[splitSlots[idx] ?? ""]; + return view === "local" || view === "remote"; + }); + if (otherPaneIndex === undefined) return; + setActivePaneIndex(otherPaneIndex); + setActiveSession(splitSlots[otherPaneIndex] ?? ""); + queueMicrotask(() => { + const el = document.querySelector(`[data-pane-index="${otherPaneIndex}"] .file-pane`) as HTMLElement | null; + el?.focus(); + }); + }, + [paneOrder, sessionFileViews, splitSlots], + ); + + const commanderPaneIndices = useMemo(() => { + if (workspaceSnapshots[activeWorkspaceId]?.kind !== "nss-commander") { + return []; + } + return paneOrder + .filter((paneIndex) => { + const view = paneFileViewForPane(paneIndex); + return view === "local" || view === "remote"; + }) + .slice(0, 2); + }, [activeWorkspaceId, paneFileViewForPane, paneOrder, workspaceSnapshots]); + + const focusCommanderPane = useCallback( + (paneIndex: number | undefined) => { + if (paneIndex == null) { + return; + } + setActivePaneIndex(paneIndex); + setActiveSession(splitSlots[paneIndex] ?? ""); + queueMicrotask(() => { + const el = document.querySelector(`[data-pane-index="${paneIndex}"] .file-pane`) as HTMLElement | null; + el?.focus(); + }); + }, + [splitSlots], + ); + + const triggerCommanderPaneKey = useCallback((key: "F5" | "Tab") => { + const activeFilePane = document.querySelector( + `[data-pane-index="${activePaneIndex}"] .file-pane`, + ) as HTMLElement | null; + if (!activeFilePane) { + return; + } + const ev = new KeyboardEvent("keydown", { key, bubbles: true }); + activeFilePane.dispatchEvent(ev); + }, [activePaneIndex]); + + const commanderActionRail = useMemo(() => { + if (workspaceSnapshots[activeWorkspaceId]?.kind !== "nss-commander") { + return null; + } + const leftPane = commanderPaneIndices[0]; + const rightPane = commanderPaneIndices[1]; + const hasTwoCommanderPanes = leftPane != null && rightPane != null; + return ( +
+
+ + + +
+
+ + + +
+
+ ); + }, [ + activePaneIndex, + activeWorkspaceId, + commanderPaneIndices, + focusCommanderPane, + handleContextAction, + triggerCommanderPaneKey, + workspaceSnapshots, + ]); + const webPanePayloadForPane = useCallback( (paneIndex: number): { url: string; title: string; allowInsecureTls?: boolean } | null => { const sid = splitSlots[paneIndex] ?? null; @@ -5259,6 +5555,11 @@ export function App() { paneContextSessionKindForPane, remoteSshSpecForPane, onLocalFilePanePathChange, + onLocalFilePaneF5Copy, + onLocalFilePaneTabSwitch, + onRemoteFilePanePathChange, + onRemoteFilePaneF5Copy, + onRemoteFilePaneTabSwitch: onLocalFilePaneTabSwitch, getFileExportDestPath, fileExportArchiveFormat, onFilePaneTitleChange, @@ -5506,6 +5807,7 @@ export function App() { parseDragPayload={parseDragPayload} sendSessionToWorkspace={sendSessionToWorkspace} createWorkspace={createWorkspace} + createNssCommanderWorkspace={createNssCommanderWorkspace} removeWorkspace={removeWorkspace} renameWorkspace={renameWorkspace} setWorkspaceVerticalStacking={setWorkspaceVerticalStacking} @@ -5525,6 +5827,7 @@ export function App() { onOpenLayoutCommandCenter={() => setIsLayoutCommandCenterOpen(true)} isBroadcastModeEnabled={isBroadcastModeEnabled} broadcastTargetCount={broadcastTargets.size} + commanderActionRail={commanderActionRail} /> {isAppSettingsOpen && ( )} - {activeAppSettingsTab === "integrations" && ( - - )} {activeAppSettingsTab === "interface" && ( )} + {activeAppSettingsTab === "connection" && connectionSubTab === "proxmux" && ( + + )} {activeAppSettingsTab === "store" && ( )} - {activeAppSettingsTab === "integrations" && integrationsSubTab === "plugins" && } - {activeAppSettingsTab === "integrations" && integrationsSubTab === "proxmux" && ( - - )} + {activeAppSettingsTab === "integrations" && } {activeAppSettingsTab === "help" && helpAboutSubTab === "help" && ( { + it("renders a compact icon-only optimal width control", () => { + render( + + vi.fn()} + onGripDoubleClick={() => vi.fn()} + onOptimalColumnWidths={vi.fn()} + /> +
, + ); + + const button = screen.getByRole("button", { name: "Optimal column widths for name, permissions, and size" }); + expect(button).toBeInTheDocument(); + expect(button.textContent?.trim()).toBe(""); + expect(button.querySelector("svg")).not.toBeNull(); + }); +}); diff --git a/apps/desktop/src/components/FilePaneTableHead.tsx b/apps/desktop/src/components/FilePaneTableHead.tsx index adf5cb2..85ce7a7 100644 --- a/apps/desktop/src/components/FilePaneTableHead.tsx +++ b/apps/desktop/src/components/FilePaneTableHead.tsx @@ -15,6 +15,19 @@ type Props = { optimalWidthsDisabled?: boolean; }; +function OptimalWidthsIcon() { + return ( + + + + + + + + + ); +} + export function FilePaneTableHead({ variant: _variant, nameWidth, @@ -96,7 +109,7 @@ export function FilePaneTableHead({ onOptimalColumnWidths(); }} > - Optimal width + diff --git a/apps/desktop/src/components/HelpPanel.tsx b/apps/desktop/src/components/HelpPanel.tsx index 24f54d5..4ed7db3 100644 --- a/apps/desktop/src/components/HelpPanel.tsx +++ b/apps/desktop/src/components/HelpPanel.tsx @@ -1,3 +1,11 @@ +import { + REPO_ISSUES_URL, + REPO_SECURITY_URL, + REPO_URL, + REPO_CHANGELOG_URL, +} from "../features/repo-links"; +import { APP_ONE_LINE, HELP_SUPPORT_INTRO } from "../features/help-app-copy"; + type HelpRow = { action: string; mouse: string; @@ -16,195 +24,203 @@ type HelpChapter = { sections: HelpSection[]; }; -const interactionSections: HelpSection[] = [ +/** Where to find major settings (balanced reference). */ +const welcomeSections: HelpSection[] = [ { - title: "Host list", + title: "Finding settings", rows: [ { - action: "Select host (for editing)", - mouse: "Single-click host row", + action: "Connection", + mouse: "Hosts, SSH directory and raw config, PROXMUX clusters.", keys: "-", }, { - action: "Open SSH in a new pane", - mouse: "Double-click host row", - keys: "When the host row is focused: Enter or Space", + action: "Identity Store", + mouse: "Users, keys, groups, tags, and per-host bindings.", + keys: "-", }, { - action: "Drag host into the grid", - mouse: "Drag host row onto pane drop zones (see Drag & drop)", + action: "Workspace", + mouse: "Views, layout and navigation (splits, broadcast, quick connect), files and export.", keys: "-", }, { - action: "Toggle favorite", - mouse: "Star button on the row", + action: "Plugins", + mouse: "Built-in plugins, store catalog, license token.", keys: "-", }, { - action: "Edit host, tags, favorite, SSH options", - mouse: "⋮ button on the row", + action: "Interface", + mouse: "Appearance and keyboard (shortcuts, leader key).", keys: "-", }, { - action: "Connect in a specific workspace", - mouse: "Right-click host row → workspace (when multiple workspaces exist)", + action: "Data & Backup", + mouse: "Encrypted backup export and import.", keys: "-", }, - ], - }, - { - title: "Pane, terminal & context menu", - rows: [ - { action: "Focus pane", mouse: "Click pane", keys: "-" }, - { action: "Pane context menu", mouse: "Right-click pane", keys: "-" }, - { action: "Split pane", mouse: "Split buttons on pane toolbar or context menu", keys: "-" }, - { action: "Resize splits", mouse: "Drag split divider", keys: "-" }, - { action: "New local terminal / Quick connect", mouse: "Context menu on pane", keys: "-" }, - { action: "Pause or resume auto-arrange", mouse: "Context menu (manual layout / restore)", keys: "-" }, - { action: "Close session / close pane", mouse: "Toolbar icons or context menu", keys: "-" }, - { action: "Open Settings", mouse: "Context menu on pane", keys: "-" }, - { - action: "Type in terminal", - mouse: "Focus terminal", - keys: "Keystrokes go to the shell (no global key capture)", + { + action: "Help & info", + mouse: "This Help page and About (version and links).", + keys: "-", }, ], }, +]; + +const interactionSections: HelpSection[] = [ { - title: "Broadcast input", + title: "Host list", rows: [ + { action: "Select a host", mouse: "Single-click a row to select it for editing or actions.", keys: "-" }, { - action: "Enable / disable broadcast", - mouse: "Broadcast icon on pane toolbar, or Settings → Layout & Navigation", - keys: "-", + action: "Open SSH in a pane", + mouse: "Double-click a row, or press Enter / Space when the row is focused.", + keys: "Enter or Space when focused", }, { - action: "Target this pane / all visible", - mouse: "Target and “all panes” icons on pane toolbar, or context menu", + action: "Drag a host into the grid", + mouse: "Drag from the list onto pane drop zones (see Drag and drop).", keys: "-", }, + { action: "Favorite", mouse: "Use the star on the row.", keys: "-" }, + { action: "Edit host details", mouse: "Use the row menu (⋮).", keys: "-" }, { - action: "Clear targets / stop broadcast", - mouse: "Context menu entries when broadcast is on", + action: "Connect in another workspace", + mouse: "Right-click the row → choose a workspace when multiple workspaces exist.", keys: "-", }, + ], + }, + { + title: "Panes, terminal, and menus", + rows: [ + { action: "Focus a pane", mouse: "Click inside the pane.", keys: "-" }, + { action: "Pane menu", mouse: "Right-click the pane.", keys: "-" }, + { action: "Split / resize", mouse: "Toolbar or context menu; drag split dividers.", keys: "-" }, { - action: "See status", - mouse: "Session footer shows enabled/disabled and target count", + action: "New local terminal / Quick connect", + mouse: "Pane context menu (same as the global shortcut when configured).", keys: "-", }, + { action: "Open Settings", mouse: "Pane context menu (same as the global shortcut when configured).", keys: "-" }, + { + action: "Type in the terminal", + mouse: "With focus in the terminal, keys go to the shell unless a chord is handled globally.", + keys: "—", + }, ], }, { - title: "Drag & drop", + title: "Broadcast", rows: [ { - action: "Open host on empty pane", - mouse: "Drag host onto empty pane (drop to open)", + action: "Turn broadcast on or off", + mouse: "Pane toolbar icon, or Settings → Layout & navigation.", keys: "-", }, { - action: "Split with host", - mouse: "Drag host onto Top / Left / Right / Bottom zone", + action: "Choose targets", + mouse: "Target and “all panes” controls on the toolbar or context menu.", keys: "-", }, + { action: "Status", mouse: "Session footer shows on/off and target count.", keys: "-" }, + ], + }, + { + title: "Drag and drop", + rows: [ + { action: "Open on empty pane", mouse: "Drop a host onto an empty pane.", keys: "-" }, + { action: "Split", mouse: "Drop onto Top / Left / Right / Bottom zones.", keys: "-" }, { action: "Replace session", - mouse: "Drag host onto center (replace) on a pane that already has a session", + mouse: "Drop onto the center of a pane that already has a session.", keys: "-", }, { action: "Move or duplicate session", - mouse: "Drag session from toolbar; drop zone chooses pane / split; same-pane center duplicates", + mouse: "Drag from the session toolbar; center drop on same pane can duplicate.", keys: "-", }, { - action: "Send session to another workspace", - mouse: "Right-click pane → Send to … (when multiple workspaces)", + action: "Send to another workspace", + mouse: "Right-click pane → Send to … when multiple workspaces exist.", keys: "-", }, ], }, { - title: "File browser", + title: "File browser (semantic colors)", rows: [ { - action: "What the colors mean", - mouse: - "Folder and file names use soft tints (archives, scripts, executables, media, code, text, data). Same idea as many terminal listings—quick visual scan, not a security label.", - keys: "-", - }, - { - action: "Folders", - mouse: "Directories use a distinct link color so they read separately from files.", - keys: "-", - }, - { - action: "Executables", + action: "Color groups", mouse: - "Unix +x bits (or .exe / .bin / …) use the executable tint. Shell scripts (.sh, .ps1, …) stay in the script tint even when executable.", + "File and folder names use soft tints (archives, scripts, executables, media, code, text, data). This is a visual aid, not a security label.", keys: "-", }, { - action: "Turn off or customize", - mouse: "Settings → Files & export: toggle semantic colors and pick per-category colors; this page explains the groups.", + action: "Customize", + mouse: "Settings → Files & export: toggle semantic colors and adjust per category.", keys: "-", }, ], }, { - title: "Layouts & navigation", + title: "File browser (NSS-Commander)", rows: [ { - action: "Layout command center", - mouse: "Footer “Layouts” — saved layouts, templates, session cleanup", + action: "Copy to other pane (file browser)", + mouse: "When the file workspace plugin is enabled, copy selection to the paired pane where supported.", keys: "-", }, { - action: "Close all / close all + reset layout", - mouse: "Layout command center (second click confirms)", - keys: "-", - }, - { - action: "Sidebar views", - mouse: "Tabs: All, Favorites, custom views (filter/sort from Settings → Views)", + action: "Switch file pane (file browser)", + mouse: "Move focus between local and remote file panes in the workspace.", keys: "-", }, + ], + }, + { + title: "Layouts and navigation", + rows: [ + { action: "Saved layouts", mouse: "Footer “Layouts” — templates and cleanup options.", keys: "-" }, + { action: "Sidebar views", mouse: "All, Favorites, and custom views from Settings → Views.", keys: "-" }, { - action: "Narrow / stacked UI", - mouse: "Hosts | Terminal tab bar; terminal pager ‹ › for multiple panes", + action: "Narrow / stacked layout", + mouse: "Hosts | Terminal tab bar; pager controls when many panes.", keys: "-", }, ], }, ]; -const sshSections: HelpSection[] = [ +/** SSH + Identity merged: host keys, proxies, then store concepts. */ +const sshIdentitySections: HelpSection[] = [ { - title: "Host key verification (saved hosts)", + title: "SSH sessions (terminal)", rows: [ { - action: "Interactive prompt (default)", + action: "How sessions start", mouse: - "OpenSSH may ask to confirm new or changed host keys. A modal can offer “Trust host” and optional “Save as default”.", + "The app launches the system OpenSSH client in a PTY. Options come from the resolved host (SSH config + app metadata + Identity Store + plugins).", keys: "-", }, { - action: "Auto-accept new keys", + action: "Host key trust", mouse: - "Host settings (⋮) → Host key verification → “Auto-accept new keys”. Uses StrictHostKeyChecking=accept-new so new keys are stored without a yes/no prompt—important for ProxyJump where prompts can be easy to miss.", + "OpenSSH may prompt for new or changed keys. The app can show a trust modal. Per-host policy lives in host settings and metadata.", keys: "-", }, { - action: "Accept any key (insecure)", + action: "Auto-accept new keys", mouse: - "Same dropdown, last option—disables meaningful host-key checks (MITM risk). Only for broken or lab setups.", + "Host menu → Host key verification → Auto-accept new keys (StrictHostKeyChecking=accept-new). Useful behind ProxyJump where prompts are easy to miss.", keys: "-", }, { - action: "Quick connect", + action: "Quick connect trust", mouse: - "Layout & Navigation → “Auto-trust host keys for quick connect” sends accept-new for one-off sessions (no saved host entry).", + "Settings → Layout & navigation: optional auto-trust for one-off Quick connect sessions.", keys: "-", }, ], @@ -213,59 +229,55 @@ const sshSections: HelpSection[] = [ title: "ProxyJump and ProxyCommand", rows: [ { - action: "Jump via another saved host", + action: "Jump via saved host", mouse: - "Host form → Proxy section: “Jump shortcut” lists host aliases; you can still type any ProxyJump string (comma-separated hops supported by OpenSSH). The bastion is a normal host entry, not a separate tab.", + "Host form → Proxy: Jump shortcut lists aliases; you can still type full ProxyJump (multi-hop supported by OpenSSH).", keys: "-", }, { - action: "Jump hosts (bastions)", + action: "Bastion tag", mouse: - "Host settings → check “Jump host (bastion)” to add the jumphost tag and include that alias in the shortcut list once at least one host is marked (until then, all aliases stay listed).", + "Mark a host as bastion so it appears in jump shortcuts once configured.", keys: "-", }, { action: "ProxyCommand presets", - mouse: - "Preset dropdown fills common patterns (e.g. ssh -W %h:%p bastion, SOCKS via nc). Edit the command line to match your environment.", - keys: "-", - }, - { - action: "Identity Store → Hosts", - mouse: - "Per-host binding: same jump shortcut + ProxyJump field, optional ProxyCommand preset and command line. Binding overrides win over store-user defaults where applicable.", + mouse: "Pick a preset or edit the command for your environment.", keys: "-", }, { - action: "Identity Store → Users", + action: "SFTP limitation", mouse: - "Optional default ProxyJump when a user is linked and the host binding leaves ProxyJump empty.", + "The file browser uses direct TCP (libssh2), not ProxyJump/ProxyCommand. Use a reachable address or work through a terminal session over a bastion.", keys: "-", }, ], }, -]; - -const identitySections: HelpSection[] = [ { - title: "What the Identity Store does", + title: "Identity Store", rows: [ { - action: "Users, keys, groups, tags", + action: "Purpose", mouse: - "Central place for SSH identities (path or encrypted keys), people-shaped records, and taxonomy. Linked on each host binding.", + "Central users, SSH keys (path or encrypted material), groups, and tags — linked through per-host bindings.", keys: "-", }, { action: "Host bindings", mouse: - "Settings → Identity Store → Hosts: pick a config host, optional store user, keys, groups/tags, ProxyJump / ProxyCommand overrides, then Save host binding.", + "Settings → Identity Store → Hosts: choose the config host, optional store user, keys, tags, and proxy overrides, then save.", + keys: "-", + }, + { + action: "Resolution order", + mouse: + "At connect time, the app merges OpenSSH config for the host with the binding and linked user (user, HostName, keys, ProxyJump, ProxyCommand, etc.).", keys: "-", }, { - action: "Session resolution", + action: "Passphrase-protected keys", mouse: - "When you connect, the app merges ~/.ssh/config host fields with the binding and store user (user, HostName, keys, ProxyJump, ProxyCommand, etc.).", + "Decrypted in memory for the session; plaintext key material is not written back to disk by the app.", keys: "-", }, ], @@ -274,112 +286,183 @@ const identitySections: HelpSection[] = [ const proxmuxSections: HelpSection[] = [ { - title: "PROXMUX (Proxmox)", + title: "PROXMUX basics", rows: [ { action: "What it is", mouse: - "Optional built-in integration for Proxmox VE: one or more clusters, guest/resource listing in the sidebar when the plugin is enabled and entitled, and Proxmox web consoles.", + "Built-in Proxmox VE integration: clusters, inventory in the sidebar when the plugin is enabled and entitled, and guest consoles.", + keys: "-", + }, + { + action: "Enable", + mouse: "Settings → Plugins: enable the PROXMUX plugin. Some builds require a license entitlement.", keys: "-", }, { - action: "Configure", + action: "Clusters", mouse: - "Settings → Integrations → PROXMUX: cluster URLs, credentials, TLS options. Use Plugins & license if the feature is gated by a license entitlement.", + "Settings → Connection → PROXMUX: add cluster URL, credentials, TLS options. Plaintext cluster secrets can be encrypted with NOSUCKSHELL_MASTER_KEY or nosuckshell.master.key — see UI on that tab.", keys: "-", }, + ], + }, + { + title: "TLS and consoles", + rows: [ { - action: "Sidebar", + action: "Certificate issues", mouse: - "When PROXMUX is available, a sidebar section lists clusters and guests; open consoles or SSH from the row actions where supported.", + "For self-signed or private CA: Allow insecure TLS and/or paste a trusted PEM; confirm when the leaf fingerprint changes.", keys: "-", }, { - action: "Web console", + action: "Where consoles open", mouse: - "Choose whether Proxmox noVNC/SPICE/HTML5 consoles open inside an app pane or in your default browser (toggle on the PROXMUX settings tab).", + "Choose embedded pane vs system browser for HTML5 / noVNC / SPICE-style consoles on the PROXMUX settings tab.", keys: "-", }, { - action: "TLS and embedded consoles", + action: "Embedded path", mouse: - "For private CA or self-signed HTTPS, use Allow insecure TLS and/or paste a trusted certificate PEM (confirm fingerprint changes when the leaf rotates). Embedded QEMU noVNC and LXC shells use a local WebSocket bridge to the cluster; the same TLS policy applies to Proxmox API calls.", + "Embedded QEMU noVNC and LXC views use a local WebSocket bridge; TLS policy matches API calls.", keys: "-", }, ], }, ]; -const settingsSections: HelpSection[] = [ +const dataPrivacySections: HelpSection[] = [ { - title: "Settings tabs (reference)", + title: "On-disk and config data", rows: [ { - action: "Connection", - mouse: "Hosts (SSH config + host list) and SSH (SSH directory override and raw config editor).", + action: "SSH config", + mouse: + "Managed Host blocks live in your effective SSH config (Settings → SSH). The app only edits entries it owns.", keys: "-", }, { - action: "Identity Store", - mouse: "Overview, Users, SSH keys, Groups, Tags — including per-host bindings (see Identity Store chapter).", + action: "nosuckshell.metadata.json", + mouse: "Favorites, tags, last used, host-key policy, default user — next to the active SSH directory.", keys: "-", }, { - action: "Workspace", - mouse: "Views (filters/sort), Layout & navigation (splits, broadcast, quick connect), Files & export.", + action: "Identity Store file", + mouse: "Encrypted JSON under the app data area; use Backup to export safely.", keys: "-", }, { - action: "Integrations", - mouse: "PROXMUX (Proxmox) and Plugins & license (built-in plugins, license token).", + action: "Layouts and views", + mouse: "Separate JSON files for layout and sidebar view profiles.", keys: "-", }, { - action: "Interface", - mouse: "Appearance (density, fonts, list tone, visual style reset) and Keyboard (shortcuts and leader key).", + action: "nosuckshell.plugins.json / nosuckshell.license.json", + mouse: "Plugin toggles and verified license payload next to the active SSH directory.", + keys: "-", + }, + { + action: "PROXMUX config", + mouse: "nosuckshell.proxmux.v1.json — see PROXMUX settings for encryption options.", + keys: "-", + }, + ], + }, + { + title: "Privacy and logs", + rows: [ + { + action: "Passwords", + mouse: + "Interactive SSH passwords go to the PTY; the app does not log them. Backup passwords exist only in memory for the current import/export.", + keys: "-", + }, + { + action: "Backups", + mouse: + "Exports are encrypted (Argon2id + authenticated encryption). Keep the password safe; it cannot be recovered by the app.", keys: "-", }, - { action: "Data & Backup", mouse: "Encrypted backup export/import.", keys: "-" }, - { action: "Help & info", mouse: "This Help page and About (version and links).", keys: "-" }, ], }, ]; -const dataSections: HelpSection[] = [ +const pluginsLicenseSections: HelpSection[] = [ { - title: "Where data lives", + title: "Plugins", rows: [ { - action: "SSH config", + action: "Built-in plugins", mouse: - "Managed host blocks live in your effective SSH config file (see Settings → SSH). The app can read/write Host entries it manages.", + "Ship in the desktop binary (e.g. NSS-Commander file workspace, PROXMUX). Toggle under Settings → Plugins.", keys: "-", }, { - action: "App metadata", + action: "Entitlements", mouse: - "Favorites, tags, last used, host key policy, default SSH user name—stored alongside your SSH directory (e.g. nosuckshell.metadata.json).", + "Some features require entitlement strings carried in a signed license token. The signature is verified offline.", + keys: "-", + }, + ], + }, + { + title: "License token", + rows: [ + { + action: "Activate", + mouse: "Settings → Plugins: paste the token, then activate. Stored as nosuckshell.license.json when valid.", keys: "-", }, { - action: "Entity store", - mouse: "Encrypted-at-rest JSON for Identity Store objects (location under the app’s data dir; use Backup to export safely).", + action: "“Waiting on entitlement”", + mouse: + "The plugin is on but your token does not include the required entitlement. Compare with Settings → Plugins and your purchase.", keys: "-", }, { - action: "Layouts & views", - mouse: "Saved layout profiles and view profiles as separate persisted files.", + action: "Clear", + mouse: "Clear the license from the Plugins tab to remove the local token file.", keys: "-", }, ], }, +]; + +const faqSections: HelpSection[] = [ { - title: "Backups", + title: "Common questions", rows: [ { - action: "Encrypted backup", + action: "Quick connect vs saved host", mouse: - "Settings → Data & Backup: export packs SSH config, metadata, store, layouts, and view profiles. Keep the password safe; see project docs for the threat model.", + "Quick connect is ephemeral unless you save the host. Saved hosts live in the sidebar and SSH config.", + keys: "-", + }, + { + action: "Why SFTP ignores my bastion", + mouse: + "File browser uses direct TCP, not OpenSSH ProxyJump. Use a direct route or use the terminal over the bastion.", + keys: "-", + }, + { + action: "Restore backup", + mouse: "Settings → Data & Backup → Import with the file path and export password.", + keys: "-", + }, + { + action: "PROXMUX TLS errors", + mouse: "Adjust Allow insecure TLS or trusted PEM on the PROXMUX connection tab.", + keys: "-", + }, + { + action: "Sidebar empty after enabling PROXMUX", + mouse: "Add at least one cluster under Settings → Connection → PROXMUX with valid URL and credentials.", + keys: "-", + }, + { + action: "Reset appearance", + mouse: "Settings → Interface → Appearance → Reset visual style.", keys: "-", }, ], @@ -391,14 +474,13 @@ const limitationsSections: HelpSection[] = [ title: "Known limitations", rows: [ { - action: "SFTP file browser", - mouse: - "Uses a direct TCP connection (libssh2). ProxyJump / ProxyCommand are not applied—use a reachable HostName or open files via a terminal session through a bastion.", + action: "SFTP and proxies", + mouse: "No ProxyJump/ProxyCommand on the SFTP path; see FAQ.", keys: "-", }, { - action: "Signing", - mouse: "Installers are not code-signed by default; your OS may show a security prompt.", + action: "Installers", + mouse: "Installers may be unsigned; the OS may show a security prompt.", keys: "-", }, ], @@ -407,57 +489,50 @@ const limitationsSections: HelpSection[] = [ const helpChapters: HelpChapter[] = [ { - id: "help-overview", - title: "Overview", - intro: [ - "NoSuckShell is a workspace for saved SSH hosts, split terminals, drag-and-drop, optional input broadcast, a dual-pane file browser, and optional PROXMUX (Proxmox) integration. Use the links below to jump between chapters.", - "While a terminal pane is focused, keystrokes go to the shell—use the toolbar, context menus, footer, or Settings for app actions.", - ], - sections: [], + id: "help-welcome", + title: "Welcome", + intro: [APP_ONE_LINE, "Open Settings from any pane menu or the sidebar. Use the table of contents below to jump to a topic."], + sections: welcomeSections, }, { id: "help-interactions", - title: "Interactions cheatsheet", + title: "Interactions", intro: [ - "Quick reference for mouse and keyboard behavior. For trust prompts, see SSH and host keys.", + "Mouse-first actions for hosts, panes, drag-and-drop, broadcast, and the file browser. Keyboard shortcuts are listed under Keyboard shortcuts and in Settings → Keyboard.", ], sections: interactionSections, }, { - id: "help-ssh", - title: "SSH, proxies, and host keys", + id: "help-ssh-identity", + title: "SSH and Identity", intro: [ - "Terminal SSH is spawned by the system OpenSSH client with options derived from each host’s saved settings and app metadata.", + "Terminal SSH uses OpenSSH with merged configuration. The Identity Store adds structured users, keys, and bindings on top of your config.", ], - sections: sshSections, - }, - { - id: "help-identity", - title: "Identity Store", - intro: [ - "Optional but powerful: link store users and keys to hosts so sessions pick up the right credentials and proxy defaults.", - ], - sections: identitySections, + sections: sshIdentitySections, }, { id: "help-proxmux", title: "PROXMUX", - intro: [ - "Optional Proxmox VE integration: clusters, guest lists, and web consoles. Requires the built-in PROXMUX plugin and, where applicable, license entitlements.", - ], + intro: ["Optional Proxmox VE integration when the plugin is enabled, configured, and entitled."], sections: proxmuxSections, }, { - id: "help-settings", - title: "Settings", - intro: ["All panels open from the context menu on a terminal pane (or the sidebar gear where available)."], - sections: settingsSections, + id: "help-data", + title: "Data, secrets, and privacy", + intro: ["Where files live, what is encrypted, and what the app avoids logging."], + sections: dataPrivacySections, + }, + { + id: "help-plugins", + title: "Plugins and license", + intro: ["Built-in plugins and how license tokens unlock entitlements in release builds."], + sections: pluginsLicenseSections, }, { - id: "help-data", - title: "Data, files, and backups", + id: "help-faq", + title: "FAQ", intro: [], - sections: dataSections, + sections: faqSections, }, { id: "help-limits", @@ -503,36 +578,38 @@ function renderSectionTable( } /** - * In-app help: chapters + cheatsheet tables. - * Keep in sync with App.tsx, context-actions.ts, features/file-pane-name-kind.ts, - * host metadata / session SSH flags, Identity Store UI, app settings tabs, and PROXMUX. + * In-app Help: TOC, chapters, shortcuts cheatsheet, support. + * Content is English-only. Update with product changes; see docs/USER_HELP.md. */ export type HelpPanelProps = { resolveHelpShortcutLabel?: (action: string) => string | undefined; shortcutCheatsheetLines?: Array<{ label: string; keys: string }>; + onOpenUrl?: (url: string) => void | Promise; }; export function HelpPanel(props: HelpPanelProps = {}) { - const { resolveHelpShortcutLabel, shortcutCheatsheetLines } = props; + const { resolveHelpShortcutLabel, shortcutCheatsheetLines, onOpenUrl } = props; + return (

Help

- Full in-app reference: interactions, SSH and trust behavior, Identity Store, settings, and data locations. + Reference for shortcuts, workflows, data locations, PROXMUX, plugins, and support. Topic tables have three + columns; if the panel is narrow, scroll horizontally inside each table to see Details and Keyboard.

{shortcutCheatsheetLines && shortcutCheatsheetLines.length > 0 ? ( -
-

Keyboard shortcuts

+
+

Keyboard shortcuts

- Rebind shortcuts in Settings → Keyboard. Use the leader key, then K (default) to jump back - here. Chords use physical keys (layout-independent). In a focused terminal, only modified shortcuts and - Escape (when a modal is open) are handled by the app. + Chords are physical keys (layout-independent). Rebind under Settings → Keyboard. The leader chord opens a + second step (default: open this Help’s shortcut list via K after leader). With focus in a + terminal, most keys go to the shell; global chords and Escape (overlays) still apply.

- +
@@ -552,12 +629,20 @@ export function HelpPanel(props: HelpPanelProps = {}) { ) : null} -
Action