diff --git a/apps/desktop/electron/main/icon-path.test.ts b/apps/desktop/electron/main/icon-path.test.ts new file mode 100644 index 0000000..f6423aa --- /dev/null +++ b/apps/desktop/electron/main/icon-path.test.ts @@ -0,0 +1,44 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { resolveIconPath, WM_CLASS } from './icon-path.js' + +const PACKAGED = '/opt/DiskPush/resources/icon.png' +const CHECKOUT = '/home/you/diskpush/apps/desktop/resources/icon.png' + +describe('resolveIconPath', () => { + it('prefers the packaged copy when it is there', () => { + expect(resolveIconPath([PACKAGED, CHECKOUT], () => true)).toBe(PACKAGED) + }) + + it('falls back to the checkout, which is the only copy in a dev run', () => { + expect(resolveIconPath([PACKAGED, CHECKOUT], (path) => path === CHECKOUT)).toBe(CHECKOUT) + }) + + it('returns undefined rather than a path to nothing', () => { + // BrowserWindow given a missing icon path logs nothing and shows no icon, + // so a guess is indistinguishable from the bug this is fixing. + expect(resolveIconPath([PACKAGED, CHECKOUT], () => false)).toBeUndefined() + }) +}) + +describe('StartupWMClass', () => { + /** + * A taskbar matches a window to its launcher by comparing the window's + * WM_CLASS with the .desktop file's StartupWMClass. The app pins WM_CLASS to + * WM_CLASS above; these two files are the other half of that agreement, and + * a rename that touched only one of them would silently un-group the window + * again — the exact symptom, with nothing failing. + */ + const root = join(import.meta.dirname, '..', '..', '..', '..') + + it('is what the installer writes into its desktop entry', () => { + const installer = readFileSync(join(root, 'scripts', 'install.sh'), 'utf8') + expect(installer).toContain(`StartupWMClass=${WM_CLASS}`) + }) + + it('is what electron-builder writes into the packaged entry', () => { + const manifest = JSON.parse(readFileSync(join(root, 'apps', 'desktop', 'package.json'), 'utf8')) + expect(manifest.build.linux.desktop.entry.StartupWMClass).toBe(WM_CLASS) + }) +}) diff --git a/apps/desktop/electron/main/icon-path.ts b/apps/desktop/electron/main/icon-path.ts new file mode 100644 index 0000000..b020fb1 --- /dev/null +++ b/apps/desktop/electron/main/icon-path.ts @@ -0,0 +1,32 @@ +/** + * Where the window icon lives, which differs between a packaged app and a + * checkout. + * + * Packaged, electron-builder copies resources/icon.png to `extraResources`, so + * it sits beside the app under `process.resourcesPath`. Run from a checkout, + * `process.resourcesPath` is Electron's own resources directory and holds + * nothing of ours, so the repo copy is the fallback. + * + * Kept apart from index.ts, and given its own `exists`, so the ordering can be + * tested without Electron and without touching a disk. + */ +export function resolveIconPath( + candidates: readonly string[], + exists: (path: string) => boolean, +): string | undefined { + // Undefined rather than a guess: BrowserWindow given a path to nothing logs + // no error and shows no icon, which is indistinguishable from not asking. + return candidates.find((candidate) => exists(candidate)) +} + +/** + * The WM_CLASS the window reports, pinned rather than inherited. + * + * A Linux taskbar matches a window to its launcher by comparing WM_CLASS with + * the .desktop file's StartupWMClass. Left alone, Chromium derives WM_CLASS + * from the executable name, so it changes with `executableName` and differs + * between the deb, the AppImage and a dev run — and a StartupWMClass written + * to match one of those is wrong for the others. Naming it here means the + * desktop entries can all state the same value. + */ +export const WM_CLASS = 'DiskPush' diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index 8c69db3..79f7300 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -1,9 +1,10 @@ -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { join, normalize } from 'node:path' import { fileURLToPath } from 'node:url' import { app, BrowserWindow, protocol, shell } from 'electron' import { contentTypeFor, resolveBundlePath } from './bundle-path.js' +import { resolveIconPath, WM_CLASS } from './icon-path.js' import { contentSecurityPolicy, inlineScriptHashes } from './csp.js' import { registerIpc } from './ipc.js' import { checkForUpdates } from './services/updater.js' @@ -23,6 +24,22 @@ const here = join(fileURLToPath(import.meta.url), '..') const isDev = !app.isPackaged && process.env.DISKPUSH_DEV === '1' const DEV_URL = 'http://localhost:3210' +/** + * Pinned before the app is ready, because Chromium reads it when it creates + * the window: a taskbar matches WM_CLASS against a .desktop file's + * StartupWMClass, and left to itself Chromium derives WM_CLASS from the + * executable name — different for the deb, the AppImage and a dev run. + */ +app.commandLine.appendSwitch('class', WM_CLASS) + +/** The window icon, which is also what a taskbar draws for the running app. */ +function iconPath(): string | undefined { + return resolveIconPath( + [join(process.resourcesPath, 'icon.png'), join(here, '..', '..', 'resources', 'icon.png')], + existsSync, + ) +} + /** * The exported renderer is served over a real scheme rather than loaded from * file://. @@ -88,6 +105,7 @@ function serveBundle(): void { } function createWindow(): BrowserWindow { + const icon = iconPath() const window = new BrowserWindow({ width: 1360, height: 860, @@ -95,6 +113,10 @@ function createWindow(): BrowserWindow { minHeight: 600, backgroundColor: '#0a0c10', title: 'DiskPush', + // Without this the window carries no _NET_WM_ICON and the taskbar draws a + // placeholder, which is what an app launched directly rather than from its + // .desktop file always looked like. + ...(icon ? { icon } : {}), webPreferences: { preload: join(here, '..', 'preload', 'index.cjs'), // The renderer gets no Node, no remote module, and its own sandbox. It diff --git a/apps/desktop/electron/main/ipc.ts b/apps/desktop/electron/main/ipc.ts index b3cc42a..1265160 100644 --- a/apps/desktop/electron/main/ipc.ts +++ b/apps/desktop/electron/main/ipc.ts @@ -1,18 +1,19 @@ -import { readFile } from 'node:fs/promises' -import { readdir, stat } from 'node:fs/promises' +import { lstat, mkdir, open, readdir, readFile, rename, rm, stat, unlink } from 'node:fs/promises' import { homedir } from 'node:os' -import { isAbsolute, join, resolve } from 'node:path' +import { isAbsolute, join, posix, resolve } from 'node:path' import { ipcMain, shell, type IpcMainInvokeEvent } from 'electron' -import { probeConnection, parseSshConfig, sshConfigConnections } from '@diskpush/ssh-core' +import { probeConnection, parseSshConfig, sshConfigConnections, type SftpBrowser } from '@diskpush/ssh-core' import { z } from 'zod' import { ConnectionInputSchema, + CreateEntryRequestSchema, + DeleteEntryRequestSchema, ExternalUrlSchema, IPC, JobIdSchema, PathSchema, RemotePathRequestSchema, - RenameRequestSchema, + RenameEntryRequestSchema, TransferRequestSchema, type IpcResult, } from '../shared/contract.js' @@ -51,6 +52,32 @@ function resolveLocalPath(input: string): string { return isAbsolute(expanded) ? expanded : resolve(expanded) } +/** Whether a path exists, without making the caller catch ENOENT. */ +async function exists(path: string): Promise { + try { + await lstat(path) + return true + } catch { + return false + } +} + +/** + * Runs `fn` against an SFTP browser for `connectionId` and always closes it. + * + * Every remote mutation had its own copy of this, and a `finally` that is + * written six times is a `finally` that eventually is not. + */ +async function withBrowser(connectionId: string | undefined, fn: (browser: SftpBrowser) => Promise): Promise { + if (!connectionId) throw new Error('That operation needs a server.') + const browser = await browserFor(await requireConnection(connectionId)) + try { + return await fn(browser) + } finally { + browser.close() + } +} + export function registerIpc(): void { // --- connections --------------------------------------------------------- @@ -164,42 +191,87 @@ export function registerIpc(): void { } }) - handle(IPC.fsMkdirRemote, RemotePathRequestSchema, async ({ connectionId, path }) => { - const connection = await requireConnection(connectionId) - const browser = await browserFor(connection) - try { - await browser.mkdir(path) - return true - } finally { - browser.close() - } + /** + * The mutating operations, local and remote. + * + * Each takes a directory and a bare entry name and joins them here, so the + * renderer names a thing inside the folder it is showing rather than handing + * the main process a path to act on. `resolveLocalPath` still expands `~`, + * but it is applied to the directory only. + */ + handle(IPC.fsMkdirLocal, CreateEntryRequestSchema, async ({ directory, name }) => { + await mkdir(join(resolveLocalPath(directory), name)) + return true }) - handle(IPC.fsRenameRemote, RenameRequestSchema, async ({ connectionId, from, to }) => { - const connection = await requireConnection(connectionId) - const browser = await browserFor(connection) - try { - await browser.rename(from, to) + handle(IPC.fsCreateFileLocal, CreateEntryRequestSchema, async ({ directory, name }) => { + // `wx` fails when the file exists rather than truncating it. + const handle = await open(join(resolveLocalPath(directory), name), 'wx') + await handle.close() + return true + }) + + handle(IPC.fsRenameLocal, RenameEntryRequestSchema, async ({ directory, from, to }) => { + const root = resolveLocalPath(directory) + const target = join(root, to) + // Renaming onto an existing name silently destroys it, so refuse. There is + // an unavoidable race here; it narrows a footgun rather than closing it. + if (await exists(target)) throw new Error(`“${to}” already exists here.`) + await rename(join(root, from), target) + return true + }) + + handle(IPC.fsDeleteLocal, DeleteEntryRequestSchema, async ({ directory, name, isDirectory }) => { + const target = join(resolveLocalPath(directory), name) + const stats = await lstat(target) + // A symlink to a directory reports as a directory to the caller; deleting + // it must still unlink the link rather than recurse into what it points at. + if (stats.isSymbolicLink()) { + await unlink(target) return true - } finally { - browser.close() } + if (stats.isDirectory() !== isDirectory) throw new Error('That item changed on disk; refresh and try again.') + if (isDirectory) await rm(target, { recursive: true }) + else await unlink(target) + return true }) - handle( - IPC.fsDeleteRemote, - z.object({ connectionId: z.string().min(1), path: PathSchema, isDirectory: z.boolean() }), - async ({ connectionId, path, isDirectory }) => { - const connection = await requireConnection(connectionId) - const browser = await browserFor(connection) - try { - if (isDirectory) await browser.rmdir(path) - else await browser.unlink(path) + handle(IPC.fsMkdirRemote, CreateEntryRequestSchema, async ({ connectionId, directory, name }) => + withBrowser(connectionId, async (browser) => { + await browser.mkdir(posix.join(directory, name)) + return true + }), + ) + + handle(IPC.fsCreateFileRemote, CreateEntryRequestSchema, async ({ connectionId, directory, name }) => + withBrowser(connectionId, async (browser) => { + await browser.createFile(posix.join(directory, name)) + return true + }), + ) + + handle(IPC.fsRenameRemote, RenameEntryRequestSchema, async ({ connectionId, directory, from, to }) => + withBrowser(connectionId, async (browser) => { + await browser.rename(posix.join(directory, from), posix.join(directory, to)) + return true + }), + ) + + handle(IPC.fsDeleteRemote, DeleteEntryRequestSchema, async ({ connectionId, directory, name, isDirectory }) => + withBrowser(connectionId, async (browser) => { + const target = posix.join(directory, name) + const stats = await browser.stat(target) + if (stats.type === 'symlink') { + await browser.unlink(target) return true - } finally { - browser.close() } - }, + if ((stats.type === 'directory') !== isDirectory) { + throw new Error('That item changed on the server; refresh and try again.') + } + if (isDirectory) await browser.removeRecursive(target) + else await browser.unlink(target) + return true + }), ) // --- transfers ----------------------------------------------------------- diff --git a/apps/desktop/electron/preload/index.ts b/apps/desktop/electron/preload/index.ts index cee35bb..d6ac72c 100644 --- a/apps/desktop/electron/preload/index.ts +++ b/apps/desktop/electron/preload/index.ts @@ -24,11 +24,20 @@ const api = { homeLocal: () => call(IPC.fsHomeLocal), listLocal: (path: string) => call(IPC.fsListLocal, { path }), listRemote: (connectionId: string, path: string) => call(IPC.fsListRemote, { connectionId, path }), - mkdirRemote: (connectionId: string, path: string) => call(IPC.fsMkdirRemote, { connectionId, path }), - renameRemote: (connectionId: string, from: string, to: string) => - call(IPC.fsRenameRemote, { connectionId, from, to }), - deleteRemote: (connectionId: string, path: string, isDirectory: boolean) => - call(IPC.fsDeleteRemote, { connectionId, path, isDirectory }), + // Mutations name an entry inside a directory; the main process joins them. + mkdir: (directory: string, name: string, connectionId?: string) => + call(connectionId ? IPC.fsMkdirRemote : IPC.fsMkdirLocal, { connectionId, directory, name }), + createFile: (directory: string, name: string, connectionId?: string) => + call(connectionId ? IPC.fsCreateFileRemote : IPC.fsCreateFileLocal, { connectionId, directory, name }), + rename: (directory: string, from: string, to: string, connectionId?: string) => + call(connectionId ? IPC.fsRenameRemote : IPC.fsRenameLocal, { connectionId, directory, from, to }), + remove: (directory: string, name: string, isDirectory: boolean, connectionId?: string) => + call(connectionId ? IPC.fsDeleteRemote : IPC.fsDeleteLocal, { + connectionId, + directory, + name, + isDirectory, + }), }, transfers: { preview: (request: unknown) => call(IPC.transfersPreview, request), diff --git a/apps/desktop/electron/shared/contract.test.ts b/apps/desktop/electron/shared/contract.test.ts index 7e7728e..b6ae6c9 100644 --- a/apps/desktop/electron/shared/contract.test.ts +++ b/apps/desktop/electron/shared/contract.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from 'vitest' import { ConnectionInputSchema, + CreateEntryRequestSchema, + DeleteEntryRequestSchema, EndpointRefSchema, + EntryNameSchema, ExternalUrlSchema, PathSchema, + RenameEntryRequestSchema, TransferOptionsSchema, TransferRequestSchema, } from './contract.js' @@ -141,3 +145,55 @@ describe('ConnectionInputSchema', () => { expect(ConnectionInputSchema.parse({ name: 'x', host: 'h', username: 'u' }).forwardAgent).toBe(false) }) }) + +describe('EntryNameSchema', () => { + /** + * Every mutating file operation joins a directory with one of these in the + * main process. If a name can carry a separator or a `..`, then "new folder" + * in a listing of /home/you is a way to write anywhere on the disk — and on + * the remote side, anywhere the SSH user can reach. + */ + it.each(['../etc', 'a/b', 'a\\b', '..', '.', '', 'x\0y', ' leading', 'trailing '])( + 'rejects %j', + (name) => { + expect(EntryNameSchema.safeParse(name).success).toBe(false) + }, + ) + + it.each(['notes.md', '.zshrc', 'a b c', 'München', 'file.tar.gz', '-rf'])('accepts %j', (name) => { + expect(EntryNameSchema.safeParse(name).success).toBe(true) + }) + + it('caps a name at 255 bytes, the limit every filesystem here shares', () => { + expect(EntryNameSchema.safeParse('a'.repeat(255)).success).toBe(true) + expect(EntryNameSchema.safeParse('a'.repeat(256)).success).toBe(false) + }) +}) + +describe('the mutating request schemas', () => { + it('takes a directory and a name, never a path to act on', () => { + const parsed = CreateEntryRequestSchema.parse({ directory: '/home/you', name: 'reports' }) + expect(parsed).toEqual({ directory: '/home/you', name: 'reports' }) + }) + + it('refuses a traversal in any name field', () => { + expect(CreateEntryRequestSchema.safeParse({ directory: '/home/you', name: '../x' }).success).toBe(false) + expect( + RenameEntryRequestSchema.safeParse({ directory: '/home/you', from: 'a', to: '../b' }).success, + ).toBe(false) + expect( + DeleteEntryRequestSchema.safeParse({ directory: '/home/you', name: '../b', isDirectory: false }).success, + ).toBe(false) + }) + + it('makes connectionId optional, because omitting it is what selects local', () => { + expect(CreateEntryRequestSchema.parse({ directory: '/tmp', name: 'x' }).connectionId).toBeUndefined() + expect( + CreateEntryRequestSchema.parse({ connectionId: 'ssh-config:dev', directory: '/tmp', name: 'x' }).connectionId, + ).toBe('ssh-config:dev') + }) + + it('requires isDirectory on a delete, so the caller cannot leave it to chance', () => { + expect(DeleteEntryRequestSchema.safeParse({ directory: '/tmp', name: 'x' }).success).toBe(false) + }) +}) diff --git a/apps/desktop/electron/shared/contract.ts b/apps/desktop/electron/shared/contract.ts index c3c2287..ee1546a 100644 --- a/apps/desktop/electron/shared/contract.ts +++ b/apps/desktop/electron/shared/contract.ts @@ -23,6 +23,11 @@ export const IPC = { fsMkdirRemote: 'fs:mkdir-remote', fsRenameRemote: 'fs:rename-remote', fsDeleteRemote: 'fs:delete-remote', + fsCreateFileRemote: 'fs:create-file-remote', + fsMkdirLocal: 'fs:mkdir-local', + fsRenameLocal: 'fs:rename-local', + fsDeleteLocal: 'fs:delete-local', + fsCreateFileLocal: 'fs:create-file-local', transfersPreview: 'transfers:preview', transfersStart: 'transfers:start', @@ -119,6 +124,52 @@ export const RenameRequestSchema = z.object({ to: PathSchema, }) +/** + * A single entry name inside a directory — never a path. + * + * Every mutating operation takes a directory plus one of these and joins them + * in the main process, so the renderer cannot walk out of the folder it is + * showing. `..`, a separator or a NUL would each be a way to do exactly that. + */ +export const EntryNameSchema = z + .string() + .min(1) + .max(255) + .refine((name) => !name.includes('/') && !name.includes('\\') && !name.includes('\0'), { + message: 'A name cannot contain a path separator.', + }) + .refine((name) => name !== '.' && name !== '..', { message: 'That name is reserved.' }) + .refine((name) => name.trim() === name, { message: 'A name cannot begin or end with a space.' }) + +/** Create a directory or an empty file: `name` inside `directory`. */ +export const CreateEntryRequestSchema = z.object({ + connectionId: ConnectionIdSchema.optional(), + directory: PathSchema, + name: EntryNameSchema, +}) + +/** Rename `from` to `to`, both inside `directory`. */ +export const RenameEntryRequestSchema = z.object({ + connectionId: ConnectionIdSchema.optional(), + directory: PathSchema, + from: EntryNameSchema, + to: EntryNameSchema, +}) + +/** + * Delete `name` from `directory`. + * + * `isDirectory` is not a hint — the main process refuses when it disagrees + * with what is actually on disk, so a mislabelled request cannot turn a + * single unlink into a recursive delete. + */ +export const DeleteEntryRequestSchema = z.object({ + connectionId: ConnectionIdSchema.optional(), + directory: PathSchema, + name: EntryNameSchema, + isDirectory: z.boolean(), +}) + /** Only http(s) may be handed to the system browser. */ export const ExternalUrlSchema = z.string().url().refine((value) => /^https?:\/\//i.test(value), { message: 'Only http and https URLs can be opened externally.', diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 048b985..030d166 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -81,7 +81,8 @@ "icon": "resources/icon.png", "desktop": { "entry": { - "Keywords": "rsync;sftp;ssh;sync;transfer;backup;" + "Keywords": "rsync;sftp;ssh;sync;transfer;backup;", + "StartupWMClass": "DiskPush" } }, "artifactName": "${productName}-${version}-linux-${arch}.${ext}" diff --git a/apps/desktop/src/components/entry-dialogs.tsx b/apps/desktop/src/components/entry-dialogs.tsx new file mode 100644 index 0000000..0c2e0b2 --- /dev/null +++ b/apps/desktop/src/components/entry-dialogs.tsx @@ -0,0 +1,141 @@ +'use client' + +import { useEffect, useState } from 'react' +import { TriangleAlert } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' + +/** + * Asks for one name. + * + * Used by New folder, New file and Rename. They differ only in their words and + * their starting value, and three near-identical dialogs would have drifted. + */ +export function NameDialog({ + open, + title, + action, + initialValue = '', + busy, + error, + onSubmit, + onClose, +}: { + open: boolean + title: string + action: string + initialValue?: string + busy: boolean + error: string | null + onSubmit: (name: string) => void + onClose: () => void +}) { + const [value, setValue] = useState(initialValue) + + // Reopening for a different entry must not show the previous entry's name. + useEffect(() => { + if (open) setValue(initialValue) + }, [open, initialValue]) + + const trimmed = value.trim() + const invalid = + trimmed === '' || trimmed === '.' || trimmed === '..' || trimmed.includes('/') || trimmed.includes('\\') + + return ( + !next && onClose()}> + + + {title} + +
{ + event.preventDefault() + if (!invalid && !busy) onSubmit(trimmed) + }} + > +
+ + setValue(event.target.value)} + /> + {/* Said before submitting rather than after failing, because the + main process rejects these names and a round trip to learn so + reads as a bug. */} + {trimmed.includes('/') || trimmed.includes('\\') ? ( +

A name cannot contain a path separator.

+ ) : null} +
+ {error ?

{error}

: null} + + + + +
+
+
+ ) +} + +/** + * Confirms a delete. + * + * Names the thing and says plainly that a folder takes its contents with it — + * there is no trash on the far side of an SFTP connection. + */ +export function DeleteDialog({ + open, + name, + isDirectory, + where, + busy, + error, + onConfirm, + onClose, +}: { + open: boolean + name: string + isDirectory: boolean + where: string + busy: boolean + error: string | null + onConfirm: () => void + onClose: () => void +}) { + return ( + !next && onClose()}> + + + + + Delete {isDirectory ? 'folder' : 'file'}? + + + {name} on {where} + {isDirectory ? ' and everything inside it' : ''} will be deleted. This cannot be undone. + + + {error ?

{error}

: null} + + + + +
+
+ ) +} diff --git a/apps/desktop/src/components/pane.tsx b/apps/desktop/src/components/pane.tsx index d1ccc6d..0c84b62 100644 --- a/apps/desktop/src/components/pane.tsx +++ b/apps/desktop/src/components/pane.tsx @@ -4,19 +4,32 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronRight, CornerLeftUp, + FilePlus2, FileText, Folder, FolderOpen, + FolderPlus, Link2, + PenLine, RefreshCw, Search, ServerCrash, SearchX, + Trash2, } from 'lucide-react' import { api, unwrap, type Connection, type FileEntry } from '@/lib/api' import { formatBytes, formatDate, formatMode, joinPath, parentPath } from '@/lib/format' import { EndpointSelect, type PaneEndpoint } from '@/components/endpoint-select' +import { DeleteDialog, NameDialog } from '@/components/entry-dialogs' import { Checkbox } from '@/components/ui/checkbox' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuTrigger, +} from '@/components/ui/context-menu' import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -214,6 +227,12 @@ export function Pane({ const [cursor, setCursor] = useState(0) const listRef = useRef(null) const anchor = useRef(null) + // The row the context menu was opened on. Null when it was opened on empty + // space, which is what distinguishes "new file here" from "rename this". + const [target, setTarget] = useState(null) + const [dialog, setDialog] = useState<'mkdir' | 'create-file' | 'rename' | 'delete' | null>(null) + const [busy, setBusy] = useState(false) + const [opError, setOpError] = useState(null) useEffect(() => { setFilter('') @@ -273,6 +292,49 @@ export function Pane({ [onNavigate, state.path], ) + const connectionId = state.endpoint.kind === 'local' ? undefined : state.endpoint.connectionId + + const closeDialog = useCallback(() => { + setDialog(null) + setOpError(null) + }, []) + + /** + * Runs one file operation and reloads the directory. + * + * The listing is re-read from the endpoint rather than patched locally: the + * server is the only thing that knows whether the operation really happened, + * and a pane that shows an optimistic folder which does not exist is worse + * than one that takes a moment. + */ + const run = useCallback( + async (operation: () => Promise) => { + setBusy(true) + setOpError(null) + try { + await operation() + closeDialog() + onNavigate(state.path) + } catch (error) { + setOpError(error instanceof Error ? error.message : String(error)) + } finally { + setBusy(false) + } + }, + [closeDialog, onNavigate, state.path], + ) + + /** Opens the menu against `entry`, selecting it the way a right-click should. */ + const aimAt = useCallback( + (entry: FileEntry | null, index: number) => { + setTarget(entry) + if (!entry) return + setCursor(index) + if (!state.selected.has(entry.name)) onChange({ selected: new Set([entry.name]) }) + }, + [onChange, state.selected], + ) + /** * Arrow keys walk the list, Enter opens, Backspace goes up. * @@ -307,6 +369,14 @@ export function Pane({ case 'Backspace': event.preventDefault() return onNavigate(parentPath(state.path)) + case 'F2': + event.preventDefault() + aimAt(visible[cursor] ?? null, cursor) + return setDialog('rename') + case 'Delete': + event.preventDefault() + aimAt(visible[cursor] ?? null, cursor) + return setDialog('delete') case 'a': if (event.ctrlKey || event.metaKey) { event.preventDefault() @@ -402,14 +472,27 @@ export function Pane({ -
+ + { + if (!(event.target as HTMLElement).closest('[data-row]')) setTarget(null) + }} + className="focus-ring h-full outline-none" + /> + } + > {state.error ? ( open(entry)} + onContextMenu={() => aimAt(entry, index)} className={cn( 'grid h-[var(--row)] cursor-default items-center gap-3 border-l-2 px-3 text-[12.5px] transition-colors', COLUMNS, @@ -487,9 +571,82 @@ export function Pane({ ) }) )} -
+ + + + onNavigate(state.path)}> + + Refresh + + + setDialog('mkdir')}> + + New folder + + setDialog('create-file')}> + + New file + + + setDialog('rename')}> + + Rename + F2 + + setDialog('delete')}> + + Delete + Del + + +
+ + run(() => + unwrap( + dialog === 'mkdir' + ? api()?.fs.mkdir(state.path, name, connectionId) + : api()?.fs.createFile(state.path, name, connectionId), + ), + ) + } + /> + + + target && run(() => unwrap(api()?.fs.rename(state.path, target.name, name, connectionId))) + } + /> + + + target && + run(() => unwrap(api()?.fs.remove(state.path, target.name, target.type === 'directory', connectionId))) + } + /> +