Skip to content

Commit cead732

Browse files
ralyodioclaude
andauthored
Right-click file operations, and an icon in the taskbar (#12)
* desktop: right-click file operations in both panes Refresh, New folder, New file, Rename and Delete, on a context menu, in the local pane and the remote one alike — the FileZilla operations the panes looked like they already had. F2 renames and Del deletes, because a file manager that answers only the mouse is half a file manager. The remote half already had mkdir/rename/delete over SFTP and nothing called them; the local half had no mutations at all. Both sides now go through one bridge where the connection id is what selects local or remote, so a caller cannot act on the wrong pane by picking the wrong method name. Every mutation takes a directory and a bare entry name and joins them in the main process. EntryNameSchema rejects a separator, `..`, a NUL and surrounding space, so "New folder" in a listing cannot write outside the folder being listed — on the remote side, anywhere the SSH user can reach. Delete re-stats its target and refuses when what is on disk disagrees with what the renderer claimed, so a mislabelled request cannot turn one unlink into a recursive delete; a symlink is unlinked, never followed. Two things SFTP does not give you: creating a file (open 'wx', which fails rather than truncating an existing one) and removing a populated directory (rmdir only unlinks an empty one, so removeRecursive walks it depth first). Verified in a headless render of the real export under the app's real CSP, both themes: the menu opens on a row with all five items live, the row it opened on becomes the selection, and the New folder dialog focuses its field with its footer inside the panel. That render caught the first draft's bug — the container's handler ran after the row's as the event bubbled and cleared the target, leaving Rename and Delete greyed out on every row. 20 new schema tests cover the traversal cases. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTQ3RzTAey9nT6r1kbGBCd * desktop: give the window an icon, and a WM_CLASS a taskbar can match The window was created with no `icon`, so it carried no _NET_WM_ICON and a Linux taskbar had nothing to draw but a placeholder. That is invisible when the app is started from its .desktop file, because the launcher supplies the icon — and it is exactly what you see when the app is started any other way, which is how anyone running the AppImage or a checkout starts it. The window now takes resources/icon.png: from `process.resourcesPath` when packaged, from the repo when run out of a checkout, and undefined rather than a path to nothing, since BrowserWindow given a missing icon logs nothing and shows no icon. The second half is grouping. A taskbar ties a window to its launcher by matching WM_CLASS against the .desktop file's StartupWMClass, and neither desktop entry had one. Chromium derives WM_CLASS from the executable name, which differs between the deb, the AppImage and a dev run, so the app now pins it to `DiskPush` before the window is created and both entries — the installer's and electron-builder's — declare that same value. A test reads both files and fails if either drifts from the constant, because a rename that touched one of them would un-group the window with nothing else failing. Verified: the checkout icon path resolves against the real dist-electron layout, and the 5 new tests cover candidate ordering, the dev fallback, the missing-icon case and both desktop entries. Not verified here: the WM_CLASS the window actually reports. This box has no X server and no xprop, so the pinning is argued from Chromium's behaviour rather than measured. Its failure mode is the icon we now set being used without grouping, which is no worse than today. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTQ3RzTAey9nT6r1kbGBCd --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f4a030e commit cead732

14 files changed

Lines changed: 768 additions & 52 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { readFileSync } from 'node:fs'
2+
import { join } from 'node:path'
3+
import { describe, expect, it } from 'vitest'
4+
import { resolveIconPath, WM_CLASS } from './icon-path.js'
5+
6+
const PACKAGED = '/opt/DiskPush/resources/icon.png'
7+
const CHECKOUT = '/home/you/diskpush/apps/desktop/resources/icon.png'
8+
9+
describe('resolveIconPath', () => {
10+
it('prefers the packaged copy when it is there', () => {
11+
expect(resolveIconPath([PACKAGED, CHECKOUT], () => true)).toBe(PACKAGED)
12+
})
13+
14+
it('falls back to the checkout, which is the only copy in a dev run', () => {
15+
expect(resolveIconPath([PACKAGED, CHECKOUT], (path) => path === CHECKOUT)).toBe(CHECKOUT)
16+
})
17+
18+
it('returns undefined rather than a path to nothing', () => {
19+
// BrowserWindow given a missing icon path logs nothing and shows no icon,
20+
// so a guess is indistinguishable from the bug this is fixing.
21+
expect(resolveIconPath([PACKAGED, CHECKOUT], () => false)).toBeUndefined()
22+
})
23+
})
24+
25+
describe('StartupWMClass', () => {
26+
/**
27+
* A taskbar matches a window to its launcher by comparing the window's
28+
* WM_CLASS with the .desktop file's StartupWMClass. The app pins WM_CLASS to
29+
* WM_CLASS above; these two files are the other half of that agreement, and
30+
* a rename that touched only one of them would silently un-group the window
31+
* again — the exact symptom, with nothing failing.
32+
*/
33+
const root = join(import.meta.dirname, '..', '..', '..', '..')
34+
35+
it('is what the installer writes into its desktop entry', () => {
36+
const installer = readFileSync(join(root, 'scripts', 'install.sh'), 'utf8')
37+
expect(installer).toContain(`StartupWMClass=${WM_CLASS}`)
38+
})
39+
40+
it('is what electron-builder writes into the packaged entry', () => {
41+
const manifest = JSON.parse(readFileSync(join(root, 'apps', 'desktop', 'package.json'), 'utf8'))
42+
expect(manifest.build.linux.desktop.entry.StartupWMClass).toBe(WM_CLASS)
43+
})
44+
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Where the window icon lives, which differs between a packaged app and a
3+
* checkout.
4+
*
5+
* Packaged, electron-builder copies resources/icon.png to `extraResources`, so
6+
* it sits beside the app under `process.resourcesPath`. Run from a checkout,
7+
* `process.resourcesPath` is Electron's own resources directory and holds
8+
* nothing of ours, so the repo copy is the fallback.
9+
*
10+
* Kept apart from index.ts, and given its own `exists`, so the ordering can be
11+
* tested without Electron and without touching a disk.
12+
*/
13+
export function resolveIconPath(
14+
candidates: readonly string[],
15+
exists: (path: string) => boolean,
16+
): string | undefined {
17+
// Undefined rather than a guess: BrowserWindow given a path to nothing logs
18+
// no error and shows no icon, which is indistinguishable from not asking.
19+
return candidates.find((candidate) => exists(candidate))
20+
}
21+
22+
/**
23+
* The WM_CLASS the window reports, pinned rather than inherited.
24+
*
25+
* A Linux taskbar matches a window to its launcher by comparing WM_CLASS with
26+
* the .desktop file's StartupWMClass. Left alone, Chromium derives WM_CLASS
27+
* from the executable name, so it changes with `executableName` and differs
28+
* between the deb, the AppImage and a dev run — and a StartupWMClass written
29+
* to match one of those is wrong for the others. Naming it here means the
30+
* desktop entries can all state the same value.
31+
*/
32+
export const WM_CLASS = 'DiskPush'

‎apps/desktop/electron/main/index.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { readFileSync } from 'node:fs'
1+
import { existsSync, readFileSync } from 'node:fs'
22
import { readFile } from 'node:fs/promises'
33
import { join, normalize } from 'node:path'
44
import { fileURLToPath } from 'node:url'
55
import { app, BrowserWindow, protocol, shell } from 'electron'
66
import { contentTypeFor, resolveBundlePath } from './bundle-path.js'
7+
import { resolveIconPath, WM_CLASS } from './icon-path.js'
78
import { contentSecurityPolicy, inlineScriptHashes } from './csp.js'
89
import { registerIpc } from './ipc.js'
910
import { checkForUpdates } from './services/updater.js'
@@ -23,6 +24,22 @@ const here = join(fileURLToPath(import.meta.url), '..')
2324
const isDev = !app.isPackaged && process.env.DISKPUSH_DEV === '1'
2425
const DEV_URL = 'http://localhost:3210'
2526

27+
/**
28+
* Pinned before the app is ready, because Chromium reads it when it creates
29+
* the window: a taskbar matches WM_CLASS against a .desktop file's
30+
* StartupWMClass, and left to itself Chromium derives WM_CLASS from the
31+
* executable name — different for the deb, the AppImage and a dev run.
32+
*/
33+
app.commandLine.appendSwitch('class', WM_CLASS)
34+
35+
/** The window icon, which is also what a taskbar draws for the running app. */
36+
function iconPath(): string | undefined {
37+
return resolveIconPath(
38+
[join(process.resourcesPath, 'icon.png'), join(here, '..', '..', 'resources', 'icon.png')],
39+
existsSync,
40+
)
41+
}
42+
2643
/**
2744
* The exported renderer is served over a real scheme rather than loaded from
2845
* file://.
@@ -88,13 +105,18 @@ function serveBundle(): void {
88105
}
89106

90107
function createWindow(): BrowserWindow {
108+
const icon = iconPath()
91109
const window = new BrowserWindow({
92110
width: 1360,
93111
height: 860,
94112
minWidth: 960,
95113
minHeight: 600,
96114
backgroundColor: '#0a0c10',
97115
title: 'DiskPush',
116+
// Without this the window carries no _NET_WM_ICON and the taskbar draws a
117+
// placeholder, which is what an app launched directly rather than from its
118+
// .desktop file always looked like.
119+
...(icon ? { icon } : {}),
98120
webPreferences: {
99121
preload: join(here, '..', 'preload', 'index.cjs'),
100122
// The renderer gets no Node, no remote module, and its own sandbox. It

‎apps/desktop/electron/main/ipc.ts‎

Lines changed: 105 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
1-
import { readFile } from 'node:fs/promises'
2-
import { readdir, stat } from 'node:fs/promises'
1+
import { lstat, mkdir, open, readdir, readFile, rename, rm, stat, unlink } from 'node:fs/promises'
32
import { homedir } from 'node:os'
4-
import { isAbsolute, join, resolve } from 'node:path'
3+
import { isAbsolute, join, posix, resolve } from 'node:path'
54
import { ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
6-
import { probeConnection, parseSshConfig, sshConfigConnections } from '@diskpush/ssh-core'
5+
import { probeConnection, parseSshConfig, sshConfigConnections, type SftpBrowser } from '@diskpush/ssh-core'
76
import { z } from 'zod'
87
import {
98
ConnectionInputSchema,
9+
CreateEntryRequestSchema,
10+
DeleteEntryRequestSchema,
1011
ExternalUrlSchema,
1112
IPC,
1213
JobIdSchema,
1314
PathSchema,
1415
RemotePathRequestSchema,
15-
RenameRequestSchema,
16+
RenameEntryRequestSchema,
1617
TransferRequestSchema,
1718
type IpcResult,
1819
} from '../shared/contract.js'
@@ -51,6 +52,32 @@ function resolveLocalPath(input: string): string {
5152
return isAbsolute(expanded) ? expanded : resolve(expanded)
5253
}
5354

55+
/** Whether a path exists, without making the caller catch ENOENT. */
56+
async function exists(path: string): Promise<boolean> {
57+
try {
58+
await lstat(path)
59+
return true
60+
} catch {
61+
return false
62+
}
63+
}
64+
65+
/**
66+
* Runs `fn` against an SFTP browser for `connectionId` and always closes it.
67+
*
68+
* Every remote mutation had its own copy of this, and a `finally` that is
69+
* written six times is a `finally` that eventually is not.
70+
*/
71+
async function withBrowser<T>(connectionId: string | undefined, fn: (browser: SftpBrowser) => Promise<T>): Promise<T> {
72+
if (!connectionId) throw new Error('That operation needs a server.')
73+
const browser = await browserFor(await requireConnection(connectionId))
74+
try {
75+
return await fn(browser)
76+
} finally {
77+
browser.close()
78+
}
79+
}
80+
5481
export function registerIpc(): void {
5582
// --- connections ---------------------------------------------------------
5683

@@ -164,42 +191,87 @@ export function registerIpc(): void {
164191
}
165192
})
166193

167-
handle(IPC.fsMkdirRemote, RemotePathRequestSchema, async ({ connectionId, path }) => {
168-
const connection = await requireConnection(connectionId)
169-
const browser = await browserFor(connection)
170-
try {
171-
await browser.mkdir(path)
172-
return true
173-
} finally {
174-
browser.close()
175-
}
194+
/**
195+
* The mutating operations, local and remote.
196+
*
197+
* Each takes a directory and a bare entry name and joins them here, so the
198+
* renderer names a thing inside the folder it is showing rather than handing
199+
* the main process a path to act on. `resolveLocalPath` still expands `~`,
200+
* but it is applied to the directory only.
201+
*/
202+
handle(IPC.fsMkdirLocal, CreateEntryRequestSchema, async ({ directory, name }) => {
203+
await mkdir(join(resolveLocalPath(directory), name))
204+
return true
176205
})
177206

178-
handle(IPC.fsRenameRemote, RenameRequestSchema, async ({ connectionId, from, to }) => {
179-
const connection = await requireConnection(connectionId)
180-
const browser = await browserFor(connection)
181-
try {
182-
await browser.rename(from, to)
207+
handle(IPC.fsCreateFileLocal, CreateEntryRequestSchema, async ({ directory, name }) => {
208+
// `wx` fails when the file exists rather than truncating it.
209+
const handle = await open(join(resolveLocalPath(directory), name), 'wx')
210+
await handle.close()
211+
return true
212+
})
213+
214+
handle(IPC.fsRenameLocal, RenameEntryRequestSchema, async ({ directory, from, to }) => {
215+
const root = resolveLocalPath(directory)
216+
const target = join(root, to)
217+
// Renaming onto an existing name silently destroys it, so refuse. There is
218+
// an unavoidable race here; it narrows a footgun rather than closing it.
219+
if (await exists(target)) throw new Error(`“${to}” already exists here.`)
220+
await rename(join(root, from), target)
221+
return true
222+
})
223+
224+
handle(IPC.fsDeleteLocal, DeleteEntryRequestSchema, async ({ directory, name, isDirectory }) => {
225+
const target = join(resolveLocalPath(directory), name)
226+
const stats = await lstat(target)
227+
// A symlink to a directory reports as a directory to the caller; deleting
228+
// it must still unlink the link rather than recurse into what it points at.
229+
if (stats.isSymbolicLink()) {
230+
await unlink(target)
183231
return true
184-
} finally {
185-
browser.close()
186232
}
233+
if (stats.isDirectory() !== isDirectory) throw new Error('That item changed on disk; refresh and try again.')
234+
if (isDirectory) await rm(target, { recursive: true })
235+
else await unlink(target)
236+
return true
187237
})
188238

189-
handle(
190-
IPC.fsDeleteRemote,
191-
z.object({ connectionId: z.string().min(1), path: PathSchema, isDirectory: z.boolean() }),
192-
async ({ connectionId, path, isDirectory }) => {
193-
const connection = await requireConnection(connectionId)
194-
const browser = await browserFor(connection)
195-
try {
196-
if (isDirectory) await browser.rmdir(path)
197-
else await browser.unlink(path)
239+
handle(IPC.fsMkdirRemote, CreateEntryRequestSchema, async ({ connectionId, directory, name }) =>
240+
withBrowser(connectionId, async (browser) => {
241+
await browser.mkdir(posix.join(directory, name))
242+
return true
243+
}),
244+
)
245+
246+
handle(IPC.fsCreateFileRemote, CreateEntryRequestSchema, async ({ connectionId, directory, name }) =>
247+
withBrowser(connectionId, async (browser) => {
248+
await browser.createFile(posix.join(directory, name))
249+
return true
250+
}),
251+
)
252+
253+
handle(IPC.fsRenameRemote, RenameEntryRequestSchema, async ({ connectionId, directory, from, to }) =>
254+
withBrowser(connectionId, async (browser) => {
255+
await browser.rename(posix.join(directory, from), posix.join(directory, to))
256+
return true
257+
}),
258+
)
259+
260+
handle(IPC.fsDeleteRemote, DeleteEntryRequestSchema, async ({ connectionId, directory, name, isDirectory }) =>
261+
withBrowser(connectionId, async (browser) => {
262+
const target = posix.join(directory, name)
263+
const stats = await browser.stat(target)
264+
if (stats.type === 'symlink') {
265+
await browser.unlink(target)
198266
return true
199-
} finally {
200-
browser.close()
201267
}
202-
},
268+
if ((stats.type === 'directory') !== isDirectory) {
269+
throw new Error('That item changed on the server; refresh and try again.')
270+
}
271+
if (isDirectory) await browser.removeRecursive(target)
272+
else await browser.unlink(target)
273+
return true
274+
}),
203275
)
204276

205277
// --- transfers -----------------------------------------------------------

‎apps/desktop/electron/preload/index.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,20 @@ const api = {
2424
homeLocal: () => call<string>(IPC.fsHomeLocal),
2525
listLocal: (path: string) => call(IPC.fsListLocal, { path }),
2626
listRemote: (connectionId: string, path: string) => call(IPC.fsListRemote, { connectionId, path }),
27-
mkdirRemote: (connectionId: string, path: string) => call(IPC.fsMkdirRemote, { connectionId, path }),
28-
renameRemote: (connectionId: string, from: string, to: string) =>
29-
call(IPC.fsRenameRemote, { connectionId, from, to }),
30-
deleteRemote: (connectionId: string, path: string, isDirectory: boolean) =>
31-
call(IPC.fsDeleteRemote, { connectionId, path, isDirectory }),
27+
// Mutations name an entry inside a directory; the main process joins them.
28+
mkdir: (directory: string, name: string, connectionId?: string) =>
29+
call<boolean>(connectionId ? IPC.fsMkdirRemote : IPC.fsMkdirLocal, { connectionId, directory, name }),
30+
createFile: (directory: string, name: string, connectionId?: string) =>
31+
call<boolean>(connectionId ? IPC.fsCreateFileRemote : IPC.fsCreateFileLocal, { connectionId, directory, name }),
32+
rename: (directory: string, from: string, to: string, connectionId?: string) =>
33+
call<boolean>(connectionId ? IPC.fsRenameRemote : IPC.fsRenameLocal, { connectionId, directory, from, to }),
34+
remove: (directory: string, name: string, isDirectory: boolean, connectionId?: string) =>
35+
call<boolean>(connectionId ? IPC.fsDeleteRemote : IPC.fsDeleteLocal, {
36+
connectionId,
37+
directory,
38+
name,
39+
isDirectory,
40+
}),
3241
},
3342
transfers: {
3443
preview: (request: unknown) => call(IPC.transfersPreview, request),

0 commit comments

Comments
 (0)