From 0ec2428766ec8ab09627b3cf9b971a16a09ea295 Mon Sep 17 00:00:00 2001 From: cachewraith Date: Wed, 30 Sep 2026 10:10:37 +0700 Subject: [PATCH 1/4] docs: mark v1.2.0 as the released version in the context brief --- docs/CONTEXT.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 16be15f..56a9c92 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -2,7 +2,7 @@ ## Purpose Electron desktop app that runs many official `claude` CLI sessions side by side, one per pane, -each with its own account (`CLAUDE_CONFIG_DIR`) and project folder. Windows + Linux. v1.1.0. +each with its own account (`CLAUDE_CONFIG_DIR`) and project folder. Windows + Linux. v1.2.0. ## Stack Electron 44, electron-vite 5, React 19, TypeScript 6, zustand, zod 4, @xterm/xterm 6, node-pty, @@ -78,4 +78,4 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e - Running claude reads CLAUDE.md/skills/plugins at start: a sharing change needs a pane restart. ## Current focus -v1.1.0 released. Unreleased: overall sharing via ~/.claude, Shift+Enter newline, Ctrl+=/-/0 font size, account folders, account + workspace icons, UI clean-up, running animation, new-release OS notification. Open: Claude mascot animation report (needs repro). +v1.2.0 released (overall ~/.claude sharing, folders + icons, Shift+Enter, font-size keys, running animation, release notification). Open: Claude mascot animation report (needs repro). From be26aae8c4a6684b5db1956534eefb8bb9b108a2 Mon Sep 17 00:00:00 2001 From: cachewraith Date: Wed, 30 Sep 2026 10:47:07 +0700 Subject: [PATCH 2/4] feat(updates): install new releases in place from Settings "Update & restart" replaces the old Download button, which opened the release page and left picking the right package to the user. It now downloads the release, installs it over the running version and relaunches: pacman/deb/rpm through a pkexec password prompt, AppImage by swapping the file, Windows as a silent NSIS install. The new-release notification opens Settings > Updates instead of the browser. This reverses the earlier "update check reports only" decision. electron-updater already handles every package type we ship, so UpdateInstaller is a small facade over it (check, download with progress, install) instead of per-package installers of our own. Where files come from stays fixed at build time (app-update.yml points at this repo), and each download is checked against the sha512 in the release's latest*.yml before it runs. A failed download or a cancelled password prompt installs nothing and returns to idle. Downloads start only from the button, and nothing installs on a plain quit: on Linux that would raise a password prompt out of nowhere. Releases now ship latest.yml / latest-linux.yml (+ the exe blockmap); the dist scripts pass --publish never so the build itself never uploads. v1.2.0 lacks these files and the updater code, so 1.2.0 must be updated to 1.3.0 by hand once; in-app updates work from there on. --- .github/workflows/build.yml | 6 +- CHANGELOG.md | 1 + docs/ARCHITECTURE.md | 6 +- docs/CONTEXT.md | 4 +- docs/DECISIONS.md | 2 + docs/TODO.md | 4 +- electron-builder.yml | 8 +- package.json | 7 +- pnpm-lock.yaml | 35 +++++++++ src/main/index.ts | 60 +++++++++++++-- src/main/ipc.ts | 13 +++- src/main/update-install.ts | 76 +++++++++++++++++++ src/main/update-notify.ts | 4 +- src/preload/index.ts | 7 +- src/renderer/app/store.ts | 32 +++++++- src/renderer/components/SettingsView.tsx | 40 ++++++++-- src/shared/ipc-channels.ts | 5 +- src/shared/ipc-contract.ts | 18 +++++ tests/unit/update-install.test.ts | 93 ++++++++++++++++++++++++ 19 files changed, 390 insertions(+), 31 deletions(-) create mode 100644 src/main/update-install.ts create mode 100644 tests/unit/update-install.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e831967..0bdac3a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,9 +32,13 @@ jobs: dist/*.deb dist/*.rpm dist/*.pacman + dist/latest-linux.yml - os: windows-latest dist: dist:win - artifacts: dist/*.exe + artifacts: | + dist/*.exe + dist/*.exe.blockmap + dist/latest.yml runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d9d813..1e31473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026-09-30 +- [updates] Settings "Update & restart" downloads the new release, installs it over this one (pacman/deb/rpm via pkexec, AppImage, silent NSIS) and relaunches; the new-release notification opens Settings instead of the browser. Works from v1.3.0 on (files: src/main/update-install.ts, src/main/{index,ipc,update-notify}.ts, src/shared/{ipc-channels,ipc-contract}.ts, src/preload/index.ts, src/renderer/app/store.ts, src/renderer/components/SettingsView.tsx, electron-builder.yml, package.json, .github/workflows/build.yml, tests/unit/update-install.test.ts) - [release] v1.2.0 (files: package.json) - [docs] Refresh README screenshots and text for sharing, folders, icons, shortcuts and update alerts (files: README.md, docs/screenshots/) - [ui] Shorter per-account sharing note; font options stay on one row (files: src/renderer/components/AccountsView.tsx, src/renderer/styles/base.css) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 432f275..34de284 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -18,7 +18,8 @@ | `platform.ts` | Desktop/window-chrome detection, default shell, extra bin dirs, spawn command, login-shell PATH | | `shared-config.ts` | Symlink/junction/hardlink ~/.claude's CLAUDE.md, settings.json, skills, plugins, agents, commands into accounts; backs up real files, replaces stale links | | `update-check.ts` | `UpdateChecker`: GitHub Releases API, cached, report only | -| `update-notify.ts` | `notifyIfNew`: native Notification for a newer release, once per version; click opens the release page | +| `update-notify.ts` | `notifyIfNew`: native Notification for a newer release, once per version; click opens Settings → Updates | +| `update-install.ts` | `UpdateInstaller`: facade over electron-updater; check, download (progress events), install and relaunch | | `paths.ts` | Config file path, accounts root, `~` resolution, `isStrictlyInside` guard | ## Renderer @@ -40,10 +41,11 @@ ## IPC surface Full list in `src/shared/ipc-channels.ts`; schemas in `src/shared/ipc-contract.ts`. Groups: pty, config, dialog, window, claude detect, account dir create/delete, openExternal (http/https only), -shared apply, app info, update check. +shared apply, app info, update check/install (+ `update:progress`, `update:show` events). ## External services - GitHub Releases API for `cachewraith/wraithgrid` (optional update check; fixed URL). +- GitHub release assets `latest.yml` / `latest-linux.yml` + installers, read by electron-updater (repo fixed in `resources/app-update.yml`). - Nothing else. No telemetry. ## Persistence diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 56a9c92..3aa49ea 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -28,7 +28,8 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e - `src/main/claude-detect.ts`: finds the `claude` binary and version - `src/main/platform.ts`: OS/desktop differences as pure functions (chrome, shell, PATH, spawn) - `src/main/shared-config.ts`: link ~/.claude's CLAUDE.md, settings.json, skills/, plugins/, agents/, commands/ into every account ("overall" mode) -- `src/main/update-check.ts`: GitHub Releases check (fixed URL, report only) +- `src/main/update-check.ts`: GitHub Releases check (fixed URL) for the Settings/status-bar notice +- `src/main/update-install.ts`: in-app update facade over electron-updater (check → download → install + relaunch) - `src/main/update-notify.ts`: OS notification for a new release, once per version (`update-notified.json` in userData) - `src/main/paths.ts`: config/account paths, `isStrictlyInside` guard - `src/preload/index.ts`: typed bridge; renderer never sees ipcRenderer @@ -76,6 +77,7 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e - AppImage on Ubuntu 24.04+/Kali blocked by AppArmor; prefer .deb. - claude only has a "new line" key via ESC CR; the terminal maps Shift+Enter to it for claude panes. - Running claude reads CLAUDE.md/skills/plugins at start: a sharing change needs a pane restart. +- In-app update needs `latest*.yml` in the release (CI uploads them from v1.3.0); 1.2.0 and older must be updated by hand once. Local `dist` scripts pass `--publish never`. ## Current focus v1.2.0 released (overall ~/.claude sharing, folders + icons, Shift+Enter, font-size keys, running animation, release notification). Open: Claude mascot animation report (needs repro). diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d240ade..78373bb 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -20,4 +20,6 @@ Entries dated 2026-09-29 are reconstructed from the code and commit history duri - 2026-09-30 | Sharing changes offer a "Restart claude panes" button instead of restarting automatically | A restart ends the running conversation | Auto-restart on change - 2026-09-30 | Account folders are one level deep, stored as `accountFolders` + `Account.folderId` | Enough to group accounts; sanitize drops dangling refs; old configs default to [] / null | Nested folders (tree ops, more UI for little gain); folders owning account lists (two sources of truth) - 2026-09-30 | Account icon is an emoji string rendered as text | No asset storage or upload path; React escapes it | Uploaded images (file handling, size limits); fixed SVG icon set only +- 2026-09-30 | In-app updates via electron-updater (reverses "update check reports only") | Users want one click, not picking a package from the release page; electron-updater already handles NSIS, AppImage, deb, rpm and pacman, and sha512-checks downloads against latest*.yml; source stays fixed (app-update.yml, this repo) | Own per-package installer (re-implements pkexec/NSIS handling); keep report-only (user rejected it) +- 2026-09-30 | Updates download only on the button, never auto-install on quit | On Linux an install needs a pkexec password prompt; one appearing on quit would be a surprise | autoDownload + autoInstallOnAppQuit (electron-updater defaults) - 2026-09-30 | New-release OS notification, once per version, state in `userData/update-notified.json` | config.json is owned and rewritten whole by the renderer, so main can't keep state there safely | Notify every launch (nags); store in config.json (lost on the next renderer save) diff --git a/docs/TODO.md b/docs/TODO.md index e5fddee..3e78f4d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,10 +1,12 @@ # TODO ## Now +- Account/workspace icons: picker (click the letter badge) is not discoverable; user wants a default icon. Awaiting which default + where to pick +- Shift+Enter "no new line" report: bytes (ESC CR) verified end to end on Hyprland and claude 2.1.285 accepts them; needs a repro (what happens: submits or nothing?) - Claude mascot ("pet") not animating in panes: needs a repro from the user (which animation, when) ## Next -- (none recorded) +- Release v1.3.0 (first release with in-app updates); install it by hand once, then test 1.3.0 → 1.3.1 in-app ## Later - macOS support (REQUIREMENTS §4 lists it; only Windows + Linux ship today) diff --git a/electron-builder.yml b/electron-builder.yml index 46b0f3b..5562454 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -103,4 +103,10 @@ nsis: artifactName: ${productName}-Setup-${version}-${arch}.${ext} appImage: artifactName: ${productName}-${version}-${arch}.${ext} -publish: null +# In-app updates (electron-updater) read latest.yml / latest-linux.yml from this repo's +# GitHub releases. The build never uploads (dist scripts pass --publish never); release.yml does. +publish: + provider: github + owner: cachewraith + repo: wraithgrid + releaseType: release diff --git a/package.json b/package.json index a818f1f..dc7cc07 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,10 @@ "format": "prettier --write .", "test": "vitest run", "test:e2e": "electron-vite build && playwright test", - "dist": "electron-vite build && electron-builder", - "dist:linux": "electron-vite build && electron-builder --linux", + "dist": "electron-vite build && electron-builder --publish never", + "dist:linux": "electron-vite build && electron-builder --linux --publish never", "dist:linux:portable": "bash scripts/dist-linux-docker.sh", - "dist:win": "electron-vite build && electron-builder --win", + "dist:win": "electron-vite build && electron-builder --win --publish never", "postinstall": "electron-builder install-app-deps" }, "devDependencies": { @@ -61,6 +61,7 @@ "@xterm/addon-unicode11": "0.9.0", "@xterm/addon-web-links": "0.12.0", "@xterm/xterm": "6.0.0", + "electron-updater": "6.8.9", "node-pty": "1.1.0", "react": "19.3.0", "react-dom": "19.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ab0340..bd846c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@xterm/xterm': specifier: 6.0.0 version: 6.0.0 + electron-updater: + specifier: 6.8.9 + version: 6.8.9(supports-color@7.2.0) node-pty: specifier: 1.1.0 version: 1.1.0 @@ -1297,6 +1300,9 @@ packages: electron-to-chromium@1.5.439: resolution: {integrity: sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q==} + electron-updater@6.8.9: + resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} + electron-vite@5.0.0: resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1730,6 +1736,13 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -2149,6 +2162,9 @@ packages: tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + tinybench@6.2.0: resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} engines: {node: '>=20.0.0'} @@ -3578,6 +3594,19 @@ snapshots: electron-to-chromium@1.5.439: {} + electron-updater@6.8.9(supports-color@7.2.0): + dependencies: + builder-util-runtime: 9.7.0(supports-color@7.2.0) + fs-extra: 10.1.0 + js-yaml: 4.3.2 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.4 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + electron-vite@5.0.0(supports-color@7.2.0)(vite@7.3.6(@types/node@24.19.0)(jiti@2.7.0)): dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) @@ -4096,6 +4125,10 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.escaperegexp@4.1.2: {} + + lodash.isequal@4.5.0: {} + lodash@4.18.1: {} lowercase-keys@2.0.0: {} @@ -4510,6 +4543,8 @@ snapshots: dependencies: semver: 5.7.2 + tiny-typed-emitter@2.1.0: {} + tinybench@6.2.0: {} tinyexec@1.3.1: {} diff --git a/src/main/index.ts b/src/main/index.ts index b5df9c6..b8475af 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,8 +2,9 @@ import os from 'node:os' import path from 'node:path' import { app, BrowserWindow, Menu, nativeTheme, net, Notification, session, shell } from 'electron' import { spawn } from 'node-pty' +import { autoUpdater, type ProgressInfo } from 'electron-updater' import { IPC } from '@shared/ipc-channels' -import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc-contract' +import type { PtyDataEvent, PtyExitEvent, UpdateProgress } from '@shared/ipc-contract' import { resolveTheme, type ThemeName } from '@shared/types' import { ConfigStore } from './config-store' import { registerIpc } from './ipc' @@ -18,6 +19,7 @@ import { } from './platform' import { PtyManager } from './pty-manager' import { UpdateChecker } from './update-check' +import { UpdateInstaller } from './update-install' import { notifyIfNew, type UpdateNotice } from './update-notify' // Requirements §9 names ~/.config/wraithgrid; Electron would default to the productName. @@ -57,7 +59,7 @@ const envReady: Promise = (async () => { ) })() -function send(channel: string, payload: PtyDataEvent | PtyExitEvent): void { +function send(channel: string, payload?: PtyDataEvent | PtyExitEvent | UpdateProgress): void { const wc = mainWindow?.webContents if (wc && !wc.isDestroyed()) wc.send(channel, payload) } @@ -182,7 +184,7 @@ app.on('second-instance', () => { mainWindow.focus() }) -/** A native notification; clicking it opens the release page and brings the window back. */ +/** A native notification; clicking it brings the window back at Settings → Updates. */ function showUpdateNotice(notice: UpdateNotice): void { if (!Notification.isSupported()) return const n = new Notification({ @@ -191,15 +193,58 @@ function showUpdateNotice(notice: UpdateNotice): void { icon: path.join(__dirname, '../../build/icon.png') }) n.on('click', () => { - void shell.openExternal(notice.url) - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore() - mainWindow.focus() + if (!mainWindow || mainWindow.isDestroyed()) { + void shell.openExternal(notice.url) + return } + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.focus() + send(IPC.updateShow) }) n.show() } +/** In-app updates through electron-updater, which picks the installer for this package type. */ +function createInstaller(): UpdateInstaller { + // Downloads start only from the Settings button, and nothing installs on a plain quit: + // on Linux that would raise a password prompt out of nowhere. + autoUpdater.autoDownload = false + autoUpdater.autoInstallOnAppQuit = false + autoUpdater.logger = null + // An 'error' event with no listener throws; the promises below already carry each error. + autoUpdater.on('error', () => {}) + return new UpdateInstaller({ + currentVersion: app.getVersion(), + check: async () => (await autoUpdater.checkForUpdates())?.updateInfo.version ?? null, + download: async (onPercent) => { + const listener = (p: ProgressInfo): void => onPercent(p.percent) + autoUpdater.on('download-progress', listener) + try { + await autoUpdater.downloadUpdate() + } finally { + autoUpdater.off('download-progress', listener) + } + }, + // Linux installs synchronously here (pkexec pacman/apt/dnf) and reports failure as an + // 'error' event, e.g. a cancelled password prompt; on success the app quits and relaunches. + install: () => { + let failed: string | null = null + const onError = (e: Error): void => { + failed = e.message + } + autoUpdater.on('error', onError) + try { + autoUpdater.quitAndInstall(true, true) + } finally { + autoUpdater.off('error', onError) + } + return Promise.resolve(failed) + }, + report: (p) => send(IPC.updateProgress, p), + log: (msg) => console.warn(`[wraithgrid] ${msg}`) + }) +} + void app.whenReady().then(() => { store.load() const updates = new UpdateChecker({ @@ -228,6 +273,7 @@ void app.whenReady().then(() => { desktop, envReady, updates, + installer: createInstaller(), onLaunchUpdateCheck: notify, isPackaged: app.isPackaged, onConfigChanged: (prev, next) => { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index c474f18..3bb6cc2 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -19,6 +19,7 @@ import { openExternalArgs, sharedApplyArgs, updateCheckArgs, + updateInstallArgs, pickPathArgs, ptyCreateArgs, ptyKillArgs, @@ -30,7 +31,8 @@ import { type SharedApplyResult, type SharedReportEntry, type SimpleResult, - type UpdateCheckResult + type UpdateCheckResult, + type UpdateInstallResult } from '@shared/ipc-contract' import { access } from 'node:fs/promises' import { detectClaude, findOnPath, resolveClaudePath } from './claude-detect' @@ -48,6 +50,7 @@ import { } from './shared-config' import type { PtyManager } from './pty-manager' import type { UpdateChecker } from './update-check' +import type { UpdateInstaller } from './update-install' export interface IpcDeps { getWindow: () => BrowserWindow | null @@ -58,6 +61,7 @@ export interface IpcDeps { /** Resolves once PATH from the login shell is merged in (launcher-started sessions). */ envReady: Promise updates: UpdateChecker + installer: UpdateInstaller /** Launch-time update checks only run in packaged builds. */ isPackaged: boolean /** Sees every launch-time check result (e.g. to raise an OS notification). */ @@ -380,6 +384,13 @@ export function registerIpc(deps: IpcDeps): void { } ) + handle( + IPC.updateInstall, + updateInstallArgs, + (): Promise => deps.installer.install(), + { ok: false, error: 'The update could not be installed.', manual: false } + ) + // ---- App info -------------------------------------------------------------------- ipcMain.handle(IPC.appInfo, (e): AppInfo | null => diff --git a/src/main/update-install.ts b/src/main/update-install.ts new file mode 100644 index 0000000..6921d96 --- /dev/null +++ b/src/main/update-install.ts @@ -0,0 +1,76 @@ +// Installs a newer release in place, so the user never picks a package by hand. +// electron-updater already knows every package type we ship (NSIS on Windows; pacman, deb +// and rpm through a pkexec prompt; AppImage by swapping the file), so this is a small +// facade over it: check, download with progress, then install and relaunch. +// Where files come from is fixed at build time (resources/app-update.yml → this repo's +// GitHub releases); nothing from config or the renderer picks a URL. electron-updater +// checks each download against the sha512 in the release's latest*.yml before running it. +import type { UpdateInstallResult, UpdateProgress } from '@shared/ipc-contract' +import { compareVersions, parseVersion } from './update-check' + +export interface InstallerDeps { + currentVersion: string + /** The newest published version, or null when this build can't update itself. */ + check(): Promise + download(onPercent: (percent: number) => void): Promise + /** Starts the install and quits; resolves to an error message when it could not start. */ + install(): Promise + report(progress: UpdateProgress): void + log(msg: string): void +} + +const MANUAL = 'This copy of Wraithgrid can’t update itself. Download the new version instead.' + +export class UpdateInstaller { + private running: Promise | null = null + + constructor(private readonly deps: InstallerDeps) {} + + /** One install at a time; a second click joins the one in progress. */ + install(): Promise { + this.running ??= this.run().finally(() => { + this.running = null + }) + return this.running + } + + private async run(): Promise { + const { deps } = this + // Errors from electron-updater carry URLs and stack text: log them, show a short message. + const fail = (error: string, detail?: unknown, manual = false): UpdateInstallResult => { + if (detail !== undefined) deps.log(`update install failed: ${String(detail)}`) + deps.report({ phase: 'idle' }) + return { ok: false, error, manual } + } + + deps.report({ phase: 'checking' }) + let version: string | null + try { + version = await deps.check() + } catch (err) { + return fail('Could not reach GitHub to fetch the update.', (err as Error).message) + } + if (version === null) return fail(MANUAL, undefined, true) + + const found = parseVersion(version) + const mine = parseVersion(deps.currentVersion) + if (!found || !mine) return fail('GitHub sent an unexpected answer.', `version ${version}`) + if (compareVersions(found, mine) <= 0) return fail('You already have the latest version.') + + deps.report({ phase: 'downloading', version, percent: 0 }) + try { + await deps.download((percent) => + deps.report({ phase: 'downloading', version, percent: Math.round(percent) }) + ) + } catch (err) { + return fail('The download failed or did not match its checksum.', (err as Error).message) + } + + deps.report({ phase: 'installing', version }) + const error = await deps.install() + if (error !== null) { + return fail('The update was cancelled or could not be installed.', error) + } + return { ok: true } + } +} diff --git a/src/main/update-notify.ts b/src/main/update-notify.ts index e374397..04decb8 100644 --- a/src/main/update-notify.ts +++ b/src/main/update-notify.ts @@ -8,7 +8,7 @@ import { RELEASES_REPO } from './update-check' export interface UpdateNotice { title: string body: string - /** The release page; only ever a page of RELEASES_REPO on github.com. */ + /** The release page, opened when no window is left to show; only RELEASES_REPO pages. */ url: string } @@ -37,7 +37,7 @@ export async function notifyIfNew(result: UpdateCheckResult, deps: NotifyDeps): if ((await lastNotified(deps.stateFile)) === version) return false deps.show({ title: `Wraithgrid ${version} is available`, - body: `You have ${result.current}. Click to open the release page and download it.`, + body: `You have ${result.current}. Click to update from Settings.`, url }) try { diff --git a/src/preload/index.ts b/src/preload/index.ts index 7f8f21f..7f86e73 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,6 +1,6 @@ import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron' import { IPC } from '@shared/ipc-channels' -import type { PtyDataEvent, PtyExitEvent, WraithApi } from '@shared/ipc-contract' +import type { PtyDataEvent, PtyExitEvent, UpdateProgress, WraithApi } from '@shared/ipc-contract' function subscribe(channel: string, cb: (payload: T) => void): () => void { const listener = (_e: IpcRendererEvent, payload: T): void => cb(payload) @@ -48,7 +48,10 @@ const api: WraithApi = { info: () => ipcRenderer.invoke(IPC.appInfo) }, update: { - check: (reason) => ipcRenderer.invoke(IPC.updateCheck, { reason }) + check: (reason) => ipcRenderer.invoke(IPC.updateCheck, { reason }), + install: () => ipcRenderer.invoke(IPC.updateInstall, {}), + onProgress: (cb) => subscribe(IPC.updateProgress, cb), + onShow: (cb) => subscribe(IPC.updateShow, () => cb()) } } diff --git a/src/renderer/app/store.ts b/src/renderer/app/store.ts index 18c8211..3fb7fc8 100644 --- a/src/renderer/app/store.ts +++ b/src/renderer/app/store.ts @@ -5,6 +5,7 @@ import type { PtyExitEvent, SharedReportEntry, UpdateCheckResult, + UpdateProgress, WraithApi } from '@shared/ipc-contract' import { baseName, slugify } from '@shared/paths' @@ -96,7 +97,13 @@ export interface AppState { activated: Record /** The OS light/dark setting, used when the theme is `system`. */ systemTheme: ThemeName - update: { checking: boolean; result: UpdateCheckResult | null } + update: { + checking: boolean + result: UpdateCheckResult | null + /** An in-app install in progress, and the last one's error. */ + install: UpdateProgress + installError: { error: string; manual: boolean } | null + } /** A Settings section to scroll to once, then cleared. */ settingsAnchor: 'updates' | null @@ -156,6 +163,8 @@ export interface AppState { detectClaude(): Promise setSystemTheme(theme: ThemeName): void checkForUpdates(reason: 'launch' | 'manual'): Promise + /** Downloads and installs the newest release, then the app restarts. */ + installUpdate(): Promise // ui setView(view: View): void @@ -437,7 +446,7 @@ export function createAppStore({ closingPaneId: null, activated: {}, systemTheme: 'dark', - update: { checking: false, result: null }, + update: { checking: false, result: null, install: { phase: 'idle' }, installError: null }, settingsAnchor: null, async init() { @@ -452,6 +461,8 @@ export function createAppStore({ if (account && !account.signedIn) get().markSignedIn(account.id) }) setInterval(refreshStatuses, 500) + api.update.onProgress((install) => set({ update: { ...get().update, install } })) + api.update.onShow(() => get().showUpdates()) set({ config, info, @@ -845,12 +856,29 @@ export function createAppStore({ // A skipped launch check keeps whatever an earlier check found. set({ update: { + ...get().update, checking: false, result: result.status === 'skipped' ? get().update.result : result } }) }, + async installUpdate() { + if (get().update.install.phase !== 'idle') return + set({ update: { ...get().update, installError: null } }) + const r = await api.update.install() + // On success the app is quitting; there is nothing left to show. + if (!r.ok) { + set({ + update: { + ...get().update, + install: { phase: 'idle' }, + installError: { error: r.error, manual: r.manual } + } + }) + } + }, + // ---- ui ------------------------------------------------------------------ setView(view) { diff --git a/src/renderer/components/SettingsView.tsx b/src/renderer/components/SettingsView.tsx index 9ef5f2c..f6541d7 100644 --- a/src/renderer/components/SettingsView.tsx +++ b/src/renderer/components/SettingsView.tsx @@ -196,7 +196,8 @@ function UpdatesRow() { const actions = useActions() const version = useApp((s) => s.info.version) const onLaunch = useApp((s) => s.config.settings.checkUpdatesOnLaunch) - const { checking, result } = useApp((s) => s.update) + const { checking, result, install, installError } = useApp((s) => s.update) + const busy = install.phase !== 'idle' const anchor = useApp((s) => s.settingsAnchor) const ref = useRef(null) @@ -206,7 +207,22 @@ function UpdatesRow() { actions.clearSettingsAnchor() }, [anchor, actions]) - const status = checking ? ( + const status = busy ? ( + + {install.phase === 'checking' + ? 'Preparing the update…' + : install.phase === 'downloading' + ? `Downloading ${install.version}… ${install.percent}%` + : `Installing ${install.version}. Wraithgrid restarts when it is done.`} + + ) : installError ? ( + <> + + + + {installError.error} + + ) : checking ? ( Checking GitHub for a newer release… ) : !result || result.status === 'skipped' ? ( Not checked yet. @@ -239,8 +255,8 @@ function UpdatesRow() {

Updates

- Checks the releases on GitHub. Nothing is downloaded or installed for you: Download opens - the release page, where you pick the package for your system. + Checks the releases on GitHub. Update & restart downloads the new version, replaces + this one and reopens Wraithgrid; running panes close. On Linux your password is asked for.

@@ -258,19 +274,29 @@ function UpdatesRow() {
- {result?.status === 'available' ? ( + {result?.status === 'available' && !installError?.manual ? ( + + ) : null} + {result?.status === 'available' && installError?.manual ? ( ) : null}
diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 7354113..3e5b663 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -19,5 +19,8 @@ export const IPC = { shellOpenExternal: 'shell:openExternal', sharedApply: 'shared:apply', appInfo: 'app:info', - updateCheck: 'update:check' + updateCheck: 'update:check', + updateInstall: 'update:install', + updateProgress: 'update:progress', + updateShow: 'update:show' } as const diff --git a/src/shared/ipc-contract.ts b/src/shared/ipc-contract.ts index 4d29045..f6f4482 100644 --- a/src/shared/ipc-contract.ts +++ b/src/shared/ipc-contract.ts @@ -41,6 +41,7 @@ export const openExternalArgs = z.object({ url: z.string().max(8192) }) export const sharedApplyArgs = z.object({ mode: z.enum(SHARED_MODES) }) /** `launch` checks are skipped in unpackaged (dev and test) builds; `manual` always runs. */ export const updateCheckArgs = z.object({ reason: z.enum(['launch', 'manual']) }) +export const updateInstallArgs = z.object({}) export interface SharedReportEntry { account: string @@ -103,6 +104,18 @@ export type UpdateCheckResult = | { status: 'skipped'; current: string } | { status: 'error'; current: string; error: string; checkedAt: number } +/** Where an in-app update is; sent from main while it downloads and installs. */ +export type UpdateProgress = + | { phase: 'idle' } + | { phase: 'checking' } + | { phase: 'downloading'; version: string; percent: number } + | { phase: 'installing'; version: string } + +export type UpdateInstallResult = + | { ok: true } + /** `manual`: this build can't update itself (e.g. a tarball); offer the release page. */ + | { ok: false; error: string; manual: boolean } + export type SimpleResult = { ok: true } | { ok: false; error: string } export type CreateDirResult = { ok: true; dir: string } | { ok: false; error: string } @@ -149,5 +162,10 @@ export interface WraithApi { update: { /** Asks GitHub Releases whether a newer version than this one is published. */ check(reason: 'launch' | 'manual'): Promise + /** Downloads the newest release, installs it over this one and restarts the app. */ + install(): Promise + onProgress(cb: (p: UpdateProgress) => void): () => void + /** The user clicked the new-release notification: show the updates section. */ + onShow(cb: () => void): () => void } } diff --git a/tests/unit/update-install.test.ts b/tests/unit/update-install.test.ts new file mode 100644 index 0000000..e6d47f4 --- /dev/null +++ b/tests/unit/update-install.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' +import type { UpdateProgress } from '@shared/ipc-contract' +import { UpdateInstaller, type InstallerDeps } from '../../src/main/update-install' + +function setup(over: Partial = {}) { + const progress: UpdateProgress[] = [] + const logs: string[] = [] + const deps: InstallerDeps = { + currentVersion: '1.2.0', + check: vi.fn(async () => '1.3.0'), + download: vi.fn(async (onPercent: (p: number) => void) => { + onPercent(42.4) + onPercent(100) + }), + install: vi.fn(async () => null), + report: (p) => progress.push(p), + log: (m) => logs.push(m), + ...over + } + return { installer: new UpdateInstaller(deps), deps, progress, logs } +} + +describe('UpdateInstaller', () => { + it('checks, downloads with progress, then installs', async () => { + const { installer, deps, progress } = setup() + expect(await installer.install()).toEqual({ ok: true }) + expect(deps.install).toHaveBeenCalledOnce() + expect(progress).toEqual([ + { phase: 'checking' }, + { phase: 'downloading', version: '1.3.0', percent: 0 }, + { phase: 'downloading', version: '1.3.0', percent: 42 }, + { phase: 'downloading', version: '1.3.0', percent: 100 }, + { phase: 'installing', version: '1.3.0' } + ]) + }) + + it('offers the release page when this build cannot update itself', async () => { + const { installer, deps, progress } = setup({ check: async () => null }) + const r = await installer.install() + expect(r).toMatchObject({ ok: false, manual: true }) + expect(deps.download).not.toHaveBeenCalled() + expect(progress.at(-1)).toEqual({ phase: 'idle' }) + }) + + it('never installs the same or an older version', async () => { + for (const v of ['1.2.0', '1.1.9', '1.2.0-beta.1']) { + const { installer, deps } = setup({ check: async () => v }) + expect(await installer.install()).toMatchObject({ ok: false, manual: false }) + expect(deps.download).not.toHaveBeenCalled() + } + }) + + it('does not install after a failed or tampered download, and logs the detail only', async () => { + const { installer, deps, logs } = setup({ + download: async () => { + throw new Error('sha512 checksum mismatch, expected abc https://example.test/x') + } + }) + const r = await installer.install() + expect(r).toEqual({ + ok: false, + error: 'The download failed or did not match its checksum.', + manual: false + }) + expect(deps.install).not.toHaveBeenCalled() + expect(logs.join()).toContain('checksum mismatch') + }) + + it('reports a cancelled install (e.g. the password prompt) and goes back to idle', async () => { + const { installer, progress } = setup({ install: async () => 'pkexec: dismissed' }) + expect(await installer.install()).toMatchObject({ ok: false, manual: false }) + expect(progress.at(-1)).toEqual({ phase: 'idle' }) + }) + + it('reports an unreachable GitHub without throwing', async () => { + const { installer } = setup({ + check: async () => { + throw new Error('net::ERR_INTERNET_DISCONNECTED') + } + }) + expect(await installer.install()).toMatchObject({ ok: false, manual: false }) + }) + + it('joins a second click to the install already running', async () => { + const { installer, deps } = setup() + const [a, b] = await Promise.all([installer.install(), installer.install()]) + expect(a).toEqual(b) + expect(deps.check).toHaveBeenCalledOnce() + // Once finished, a new attempt is allowed. + await installer.install() + expect(deps.check).toHaveBeenCalledTimes(2) + }) +}) From cac3ac4d448dcc747d383ddb1e995f6e4a8af44a Mon Sep 17 00:00:00 2001 From: cachewraith Date: Wed, 30 Sep 2026 10:47:21 +0700 Subject: [PATCH 3/4] test(terminal): check Shift+Enter reaches claude panes as ESC CR Shift+Enter in a claude pane is meant to insert a new line by sending ESC CR, which claude reads as "new line"; plain Enter must stay a bare CR that submits. Nothing tested that end to end, and a user reported that Shift+Enter does not give a new line. The new e2e test runs a claude pane on raw-keys.sh, a stand-in that puts its TTY in raw mode and prints every input byte as hex, then types a, Shift+Enter, b, Enter and expects 61 1b 0d 62 0d. The same bytes were confirmed manually through the real Wayland input path (wtype on Hyprland), and claude 2.1.285 inserts a new line for ESC CR in a PTY. So the report is not reproduced yet; this test keeps the Wraithgrid half from regressing while a repro is pending. --- CHANGELOG.md | 1 + docs/CONTEXT.md | 3 +- tests/e2e/keys.spec.ts | 73 ++++++++++++++++++++++++++++++++++++++ tests/fixtures/raw-keys.sh | 10 ++++++ 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/keys.spec.ts create mode 100755 tests/fixtures/raw-keys.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e31473..3eedcd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 2026-09-30 - [updates] Settings "Update & restart" downloads the new release, installs it over this one (pacman/deb/rpm via pkexec, AppImage, silent NSIS) and relaunches; the new-release notification opens Settings instead of the browser. Works from v1.3.0 on (files: src/main/update-install.ts, src/main/{index,ipc,update-notify}.ts, src/shared/{ipc-channels,ipc-contract}.ts, src/preload/index.ts, src/renderer/app/store.ts, src/renderer/components/SettingsView.tsx, electron-builder.yml, package.json, .github/workflows/build.yml, tests/unit/update-install.test.ts) +- [tests] E2E check that Shift+Enter sends ESC CR and Enter a bare CR to claude panes (files: tests/e2e/keys.spec.ts, tests/fixtures/raw-keys.sh) - [release] v1.2.0 (files: package.json) - [docs] Refresh README screenshots and text for sharing, folders, icons, shortcuts and update alerts (files: README.md, docs/screenshots/) - [ui] Shorter per-account sharing note; font options stay on one row (files: src/renderer/components/AccountsView.tsx, src/renderer/styles/base.css) diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 3aa49ea..7e7ec0e 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -45,7 +45,7 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e - `src/renderer/layout/`: pure split-tree ops, presets, directional focus - `src/renderer/lib/`: PtyBus, status detection, terminal themes, ANSI strip, scheduling - `src/renderer/styles/`: design tokens + base CSS -- `tests/unit/`, `tests/e2e/`, `tests/fixtures/fake-claude.sh`: tests + fake CLI +- `tests/unit/`, `tests/e2e/`, `tests/fixtures/{fake-claude,raw-keys}.sh`: tests + fake CLIs (raw-keys prints input bytes as hex) - `scripts/`: Docker Linux build, multi-distro package smoke test - `build/`: icons; `.github/workflows/`: build.yml (CI checks; packages only when release.yml calls it), release.yml (tag → release) - `docs/REQUIREMENTS.md`: original requirements (FR-*/NFR-* IDs); `docs/screenshots/` @@ -78,6 +78,7 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e - claude only has a "new line" key via ESC CR; the terminal maps Shift+Enter to it for claude panes. - Running claude reads CLAUDE.md/skills/plugins at start: a sharing change needs a pane restart. - In-app update needs `latest*.yml` in the release (CI uploads them from v1.3.0); 1.2.0 and older must be updated by hand once. Local `dist` scripts pass `--publish never`. +- e2e/manual tests: `pgrep -f 'out/main/index.js'` also matches your own shell command; match the electron binary path instead. ## Current focus v1.2.0 released (overall ~/.claude sharing, folders + icons, Shift+Enter, font-size keys, running animation, release notification). Open: Claude mascot animation report (needs repro). diff --git a/tests/e2e/keys.spec.ts b/tests/e2e/keys.spec.ts new file mode 100644 index 0000000..c9f16a3 --- /dev/null +++ b/tests/e2e/keys.spec.ts @@ -0,0 +1,73 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { _electron as electron, expect, test, type Page } from '@playwright/test' + +const ROOT = path.resolve(__dirname, '../..') +const MAIN = path.join(ROOT, 'out/main/index.js') +const RAW_KEYS = path.join(ROOT, 'tests/fixtures/raw-keys.sh') + +// Uses a bash stand-in for claude that prints each input byte in hex. +test.skip(process.platform === 'win32', 'POSIX-only fixture') + +/** Hex bytes the pane's process has received so far, e.g. "611b0d62". */ +async function bytes(win: Page): Promise { + const text = await win.locator('section.pane .xterm-rows').innerText() + return text.replace(/\s+/g, '').replace(/^.*READY/, '') +} + +test('Shift+Enter reaches claude as ESC CR; Enter stays a bare CR', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wraithgrid-e2e-')) + const home = path.join(tmp, 'home') + const userData = path.join(tmp, 'userdata') + fs.mkdirSync(home, { recursive: true }) + fs.mkdirSync(userData, { recursive: true }) + fs.writeFileSync( + path.join(userData, 'config.json'), + JSON.stringify({ + version: 1, + claudePath: RAW_KEYS, + accounts: [ + { + id: 'a-one', + name: 'one', + configDir: '~/.wraithgrid/accounts/one', + color: '#7c5cff', + signedIn: true, + imported: false + } + ], + workspaces: [ + { + id: 'ws-main', + name: 'main', + panes: [ + { id: 'p-keys', accountId: 'a-one', cwd: '~', args: [], shell: false, title: 'keys' } + ], + layout: null + } + ], + activeWorkspace: 'ws-main', + recentFolders: [], + settings: {} + }) + ) + + const app = await electron.launch({ + args: [MAIN], + env: { ...process.env, HOME: home, WRAITHGRID_USER_DATA_DIR: userData } + }) + try { + const win = await app.firstWindow() + await expect.poll(() => win.locator('section.pane .xterm-rows').innerText()).toContain('READY') + await win.locator('section.pane .xterm').click() + + await win.keyboard.type('a') + await win.keyboard.press('Shift+Enter') + await win.keyboard.type('b') + await win.keyboard.press('Enter') + await expect.poll(() => bytes(win)).toBe('611b0d620d') + } finally { + await app.close() + } +}) diff --git a/tests/fixtures/raw-keys.sh b/tests/fixtures/raw-keys.sh new file mode 100755 index 0000000..c1d0a4c --- /dev/null +++ b/tests/fixtures/raw-keys.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Stand-in for claude in key tests: puts the TTY in raw mode and prints every input byte +# as hex, so a test sees exactly what a key press sends (ESC CR, not a bare CR). +if [ "$1" = "--version" ]; then + echo "0.0.0 (raw keys)" + exit 0 +fi +stty raw -echo opost +echo "READY" +exec od -An -tx1 -v -w1 From a01120d8a94a926abf1f367fece982a46824c6c5 Mon Sep 17 00:00:00 2001 From: cachewraith Date: Wed, 30 Sep 2026 10:47:32 +0700 Subject: [PATCH 4/4] feat(sidebar): animate collapse and add right-click menus Collapsing or expanding the sidebar used to jump between widths and swap its content in one frame. The width now transitions over 220 ms. While it moves (.moving) the content keeps a fixed width, so it is revealed or clipped instead of reflowing, and newly shown content fades in. The fixed width applies only during the move: at rest the content must still fit beside a vertical scrollbar. Terminal refits are already debounced (50 ms), so panes resize once, after the move. All of it is off under prefers-reduced-motion. Right-clicking a folder offers rename, collapse/expand, new folder and delete (its accounts stay). Right-clicking a workspace offers rename, change icon and delete. Delete is two-step inside the menu because it closes the workspace's panes, and it is disabled for the last workspace. ContextMenu is a plain, data-driven component: arrow keys move, Escape/Tab/outside click close, Shift+F10 or the Menu key opens it at the row. The icon grid is split out of IconPicker as IconPopover so the menu can open it without a badge. Both portal to : the fade makes each sidebar section its own stacking context, and without the portal the workspace menu rendered behind the Accounts section. --- CHANGELOG.md | 2 + docs/CONTEXT.md | 3 +- docs/DECISIONS.md | 2 + src/renderer/components/ContextMenu.tsx | 118 ++++++++++++ src/renderer/components/IconPicker.tsx | 143 ++++++++------ src/renderer/components/Sidebar.tsx | 199 ++++++++++++++++---- src/renderer/components/SidebarAccounts.tsx | 53 +++++- src/renderer/styles/base.css | 109 +++++++++++ tests/e2e/sidebar-menus.spec.ts | 91 +++++++++ 9 files changed, 619 insertions(+), 101 deletions(-) create mode 100644 src/renderer/components/ContextMenu.tsx create mode 100644 tests/e2e/sidebar-menus.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3eedcd1..d12a846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 2026-09-30 +- [sidebar] Collapse/expand animates the width (220 ms; content is revealed/clipped, not reflowed, and fades in; off under reduced motion) (files: src/renderer/components/Sidebar.tsx, src/renderer/styles/base.css) +- [sidebar] Right-click a folder (rename, collapse, new folder, delete) or a workspace (rename, change icon, two-step delete) via a new keyboard-accessible `ContextMenu`; icon popover split out as `IconPopover`; both portal to (files: src/renderer/components/{ContextMenu,IconPicker,Sidebar,SidebarAccounts}.tsx, src/renderer/styles/base.css, tests/e2e/sidebar-menus.spec.ts) - [updates] Settings "Update & restart" downloads the new release, installs it over this one (pacman/deb/rpm via pkexec, AppImage, silent NSIS) and relaunches; the new-release notification opens Settings instead of the browser. Works from v1.3.0 on (files: src/main/update-install.ts, src/main/{index,ipc,update-notify}.ts, src/shared/{ipc-channels,ipc-contract}.ts, src/preload/index.ts, src/renderer/app/store.ts, src/renderer/components/SettingsView.tsx, electron-builder.yml, package.json, .github/workflows/build.yml, tests/unit/update-install.test.ts) - [tests] E2E check that Shift+Enter sends ESC CR and Enter a bare CR to claude panes (files: tests/e2e/keys.spec.ts, tests/fixtures/raw-keys.sh) - [release] v1.2.0 (files: package.json) diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 7e7ec0e..4fa0689 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -41,7 +41,7 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e - `src/renderer/main.tsx`: composition root (builds Services) - `src/renderer/app/store.ts`: zustand store, all app actions (largest file) - `src/renderer/app/{App,services,shortcuts}`: root view, DI context, key matching -- `src/renderer/components/`: UI (PaneGrid, Pane, Terminal, dialogs, Settings/Accounts views; SidebarAccounts = folders + drag-drop; AccountIcon/Avatar = badges; IconPicker) +- `src/renderer/components/`: UI (PaneGrid, Pane, Terminal, dialogs, Settings/Accounts views; SidebarAccounts = folders + drag-drop; AccountIcon/Avatar = badges; IconPicker/IconPopover; ContextMenu = right-click menus) - `src/renderer/layout/`: pure split-tree ops, presets, directional focus - `src/renderer/lib/`: PtyBus, status detection, terminal themes, ANSI strip, scheduling - `src/renderer/styles/`: design tokens + base CSS @@ -78,6 +78,7 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e - claude only has a "new line" key via ESC CR; the terminal maps Shift+Enter to it for claude panes. - Running claude reads CLAUDE.md/skills/plugins at start: a sharing change needs a pane restart. - In-app update needs `latest*.yml` in the release (CI uploads them from v1.3.0); 1.2.0 and older must be updated by hand once. Local `dist` scripts pass `--publish never`. +- Popovers/menus must portal to : sidebar sections animate opacity (own stacking context) and the sidebar clips. - e2e/manual tests: `pgrep -f 'out/main/index.js'` also matches your own shell command; match the electron binary path instead. ## Current focus diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 78373bb..c2af093 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -22,4 +22,6 @@ Entries dated 2026-09-29 are reconstructed from the code and commit history duri - 2026-09-30 | Account icon is an emoji string rendered as text | No asset storage or upload path; React escapes it | Uploaded images (file handling, size limits); fixed SVG icon set only - 2026-09-30 | In-app updates via electron-updater (reverses "update check reports only") | Users want one click, not picking a package from the release page; electron-updater already handles NSIS, AppImage, deb, rpm and pacman, and sha512-checks downloads against latest*.yml; source stays fixed (app-update.yml, this repo) | Own per-package installer (re-implements pkexec/NSIS handling); keep report-only (user rejected it) - 2026-09-30 | Updates download only on the button, never auto-install on quit | On Linux an install needs a pkexec password prompt; one appearing on quit would be a surprise | autoDownload + autoInstallOnAppQuit (electron-updater defaults) +- 2026-09-30 | Sidebar animates `width`, with content fixed-width only while moving (`.moving`) | Content is revealed/clipped instead of reflowing mid-animation; at rest it must still fit beside a scrollbar; terminal refit is already debounced (50 ms) so panes resize once | Transform/overlay slide (main area would jump at the end); permanent min-width (clips under a scrollbar) +- 2026-09-30 | Workspace delete from the right-click menu is two-step inside the menu | Deleting closes the workspace's panes; no extra dialog | Confirm dialog (heavier); one-click delete (loses running panes) - 2026-09-30 | New-release OS notification, once per version, state in `userData/update-notified.json` | config.json is owned and rewritten whole by the renderer, so main can't keep state there safely | Notify every launch (nags); store in config.json (lost on the next renderer save) diff --git a/src/renderer/components/ContextMenu.tsx b/src/renderer/components/ContextMenu.tsx new file mode 100644 index 0000000..30d34a9 --- /dev/null +++ b/src/renderer/components/ContextMenu.tsx @@ -0,0 +1,118 @@ +// Right-click menu for sidebar rows. Portaled to : the sidebar scrolls, clips, and +// its sections form stacking contexts while they fade in. Kept inside the window; closed by +// Escape, an outside click or a pick. Arrow keys move between items; Shift+F10 or the Menu +// key opens it from the keyboard. +import { useLayoutEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react' +import { createPortal } from 'react-dom' + +export interface MenuItem { + label: string + icon?: ReactNode + danger?: boolean + disabled?: boolean + /** Return false to keep the menu open (a two-step delete). */ + onSelect(): void | false +} + +export interface MenuPoint { + x: number + y: number +} + +/** Where a menu opens: at the pointer, or under the row when the keyboard opened it. */ +export function menuPoint(e: MouseEvent): MenuPoint { + if (e.clientX === 0 && e.clientY === 0) { + const r = e.currentTarget.getBoundingClientRect() + return { x: r.left + 12, y: r.bottom } + } + return { x: e.clientX, y: e.clientY } +} + +const EDGE = 4 + +export function ContextMenu({ + at, + label, + items, + onClose +}: { + at: MenuPoint + label: string + items: MenuItem[] + onClose(): void +}) { + const ref = useRef(null) + const [pos, setPos] = useState(at) + + // Measure once and pull the menu back inside the window, then focus the first item. + useLayoutEffect(() => { + const el = ref.current + if (!el) return + const { width, height } = el.getBoundingClientRect() + setPos({ + x: Math.max(EDGE, Math.min(at.x, window.innerWidth - width - EDGE)), + y: Math.max(EDGE, Math.min(at.y, window.innerHeight - height - EDGE)) + }) + el.querySelector('button:not(:disabled)')?.focus() + }, [at]) + + const move = (step: 1 | -1): void => { + const buttons = [...(ref.current?.querySelectorAll('button:not(:disabled)') ?? [])] + if (!buttons.length) return + const i = buttons.indexOf(document.activeElement as Element) + ;(buttons[(i + step + buttons.length) % buttons.length] as HTMLButtonElement).focus() + } + + return createPortal( + <> +
{ + e.preventDefault() + onClose() + }} + /> +
e.preventDefault()} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Escape' || e.key === 'Tab') { + e.preventDefault() + onClose() + } else if (e.key === 'ArrowDown') { + e.preventDefault() + move(1) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + move(-1) + } + }} + > + {items.map((it, i) => ( + + ))} +
+ , + document.body + ) +} diff --git a/src/renderer/components/IconPicker.tsx b/src/renderer/components/IconPicker.tsx index 059bd2e..be4921a 100644 --- a/src/renderer/components/IconPicker.tsx +++ b/src/renderer/components/IconPicker.tsx @@ -1,6 +1,7 @@ // Click a badge to pick an emoji for it, type any emoji, or go back to the letter. -// The popover is fixed-positioned because tables and dialogs clip overflowing children. +// The popover is portaled to because tables, dialogs and the sidebar clip it. import { useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { ACCOUNT_ICON_MAX, ACCOUNT_ICONS } from '@shared/types' import { Avatar } from './AccountIcon' @@ -14,18 +15,12 @@ interface Props { export function IconPicker({ icon, name, color, size = 22, onPick }: Props) { const [at, setAt] = useState<{ top: number; left: number } | null>(null) - const [custom, setCustom] = useState('') const btnRef = useRef(null) // Focus goes back to the badge so keys (Esc in a dialog) keep working after a pick. const close = (): void => { setAt(null) - setCustom('') btnRef.current?.focus() } - const pick = (next: string): void => { - onPick(next) - close() - } return ( @@ -39,66 +34,98 @@ export function IconPicker({ icon, name, color, size = 22, onPick }: Props) { e.stopPropagation() if (at) return close() const r = e.currentTarget.getBoundingClientRect() - setAt({ top: r.bottom + 6, left: Math.min(r.left, window.innerWidth - 280) }) + setAt({ top: r.bottom + 6, left: r.left }) }} > - {at ?
: null} {at ? ( -
e.stopPropagation()} - onKeyDown={(e) => { - e.stopPropagation() - if (e.key === 'Escape') close() + { + onPick(next) + close() }} - > -
- {ACCOUNT_ICONS.map((ic) => ( - - ))} -
-
{ - e.preventDefault() - if (custom.trim()) pick(custom) - }} - > - setCustom(e.target.value)} - autoFocus - /> - + onClose={close} + /> + ) : null} + + ) +} + +/** The emoji grid on its own, for opening from somewhere other than the badge (a menu). */ +export function IconPopover({ + at, + icon, + name, + onPick, + onClose +}: { + at: { top: number; left: number } + icon: string + name: string + onPick(icon: string): void + onClose(): void +}) { + const [custom, setCustom] = useState('') + return createPortal( + <> +
+
e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Escape') onClose() + }} + > +
+ {ACCOUNT_ICONS.map((ic) => ( - + ))}
- ) : null} - +
{ + e.preventDefault() + if (custom.trim()) onPick(custom) + }} + > + setCustom(e.target.value)} + autoFocus + /> + + +
+
+ , + document.body ) } diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index d99b005..fe8c244 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -1,8 +1,19 @@ -import type { Config } from '@shared/types' +import { useEffect, useRef, useState } from 'react' +import type { Config, Workspace } from '@shared/types' import { useActions, useApp } from '../app/services' import { SHORTCUT_HINT } from '../app/shortcuts' import { AccountIcon, Avatar } from './AccountIcon' -import { IconGrid4, IconPlus, IconSidebar, IconUserPlus } from './icons' +import { ContextMenu, menuPoint, type MenuPoint } from './ContextMenu' +import { IconPopover } from './IconPicker' +import { + IconGrid4, + IconPlus, + IconRename, + IconSidebar, + IconSun, + IconTrash, + IconUserPlus +} from './icons' import { SidebarAccounts } from './SidebarAccounts' export function accountColorsFor(config: Config, wsId: string): string[] { @@ -12,18 +23,164 @@ export function accountColorsFor(config: Config, wsId: string): string[] { return ids.flatMap((id) => config.accounts.find((a) => a.id === id)?.color ?? []) } +/** How long the collapse/expand transition runs (matches `.side` in base.css), plus slack. */ +const SIDE_MOVE_MS = 260 + +/** One workspace in the sidebar: click to switch, right-click to rename, re-icon or delete. */ +function WorkspaceRow({ w, index }: { w: Workspace; index: number }) { + const actions = useActions() + const config = useApp((s) => s.config) + const on = useApp((s) => s.config.activeWorkspace === w.id && s.view === 'grid') + const [menu, setMenu] = useState(null) + const [confirmDelete, setConfirmDelete] = useState(false) + const [renaming, setRenaming] = useState(null) + const [iconAt, setIconAt] = useState<{ top: number; left: number } | null>(null) + const rowRef = useRef(null) + const key = index < 9 ? index + 1 : null + const canDelete = config.workspaces.length > 1 + const panes = w.panes.length + + const saveRename = (): void => { + if (renaming?.trim()) actions.renameWorkspace(w.id, renaming) + setRenaming(null) + } + + if (renaming !== null) { + return ( +
+ + e.currentTarget.select()} + onChange={(e) => setRenaming(e.target.value)} + onBlur={saveRename} + onKeyDown={(e) => { + if (e.key === 'Enter') saveRename() + if (e.key === 'Escape') { + e.stopPropagation() + setRenaming(null) + } + }} + /> +
+ ) + } + + return ( + <> + + {menu ? ( + setMenu(null)} + items={[ + { label: 'Rename', icon: , onSelect: () => setRenaming(w.name) }, + { + label: 'Change icon', + icon: , + // The icon grid opens under the row. + onSelect: () => { + const r = rowRef.current?.getBoundingClientRect() + if (r) setIconAt({ top: r.bottom + 6, left: r.left + 8 }) + } + }, + { + label: !canDelete + ? 'Delete (last workspace)' + : confirmDelete + ? panes + ? `Click again: closes ${panes} ${panes === 1 ? 'pane' : 'panes'}` + : 'Click again to delete' + : 'Delete workspace', + icon: , + danger: true, + disabled: !canDelete, + onSelect: () => { + if (!confirmDelete) { + setConfirmDelete(true) + return false + } + actions.deleteWorkspace(w.id) + } + } + ]} + /> + ) : null} + {iconAt ? ( + { + actions.setWorkspaceIcon(w.id, icon) + setIconAt(null) + }} + onClose={() => setIconAt(null)} + /> + ) : null} + + ) +} + export function Sidebar() { const actions = useActions() const collapsed = useApp((s) => s.config.settings.sidebarCollapsed) - const view = useApp((s) => s.view) const config = useApp((s) => s.config) - const { workspaces, accounts, activeWorkspace: activeId } = config + const { workspaces, accounts } = config const toggleLabel = collapsed ? 'Expand sidebar' : 'Collapse sidebar' + // While the width animates, the content keeps a fixed width (`.moving`) so it is revealed + // or clipped instead of reflowing. Only then: at rest it must fit beside a scrollbar. + const [shown, setShown] = useState(collapsed) + const [moving, setMoving] = useState(false) + if (shown !== collapsed) { + setShown(collapsed) + setMoving(true) + } + useEffect(() => { + if (!moving) return + const t = setTimeout(() => setMoving(false), SIDE_MOVE_MS) + return () => clearTimeout(t) + }, [moving, collapsed]) const newPane = (): void => actions.openModal({ kind: 'newPane', slotId: null }) return ( -
diff --git a/src/renderer/components/SidebarAccounts.tsx b/src/renderer/components/SidebarAccounts.tsx index fe246c7..79ea029 100644 --- a/src/renderer/components/SidebarAccounts.tsx +++ b/src/renderer/components/SidebarAccounts.tsx @@ -1,5 +1,5 @@ // The sidebar's account list: one level of folders, and drag an account onto a folder -// (or onto the list itself, for the top level) to move it. +// (or onto the list itself, for the top level) to move it. Right-click a folder to edit it. import { useState } from 'react' import { DndContext, @@ -16,7 +16,8 @@ import { import type { Account, AccountFolder } from '@shared/types' import { useActions, useApp } from '../app/services' import { AccountIcon } from './AccountIcon' -import { IconClose, IconPlus } from './icons' +import { ContextMenu, menuPoint, type MenuPoint } from './ContextMenu' +import { IconClose, IconFolder, IconPlus, IconRename, IconTrash } from './icons' const ROOT = 'folder:root' // The zone under the pointer; if none, the one the dragged row overlaps. @@ -54,16 +55,28 @@ function AccountRow({ account }: { account: Account }) { function FolderRow({ folder, accounts }: { folder: AccountFolder; accounts: Account[] }) { const actions = useActions() const { setNodeRef, isOver } = useDroppable({ id: folderDropId(folder.id) }) + const folderCount = useApp((s) => s.config.accountFolders.length) const [renaming, setRenaming] = useState(false) const [name, setName] = useState(folder.name) + const [menu, setMenu] = useState(null) const save = (): void => { actions.renameFolder(folder.id, name) setRenaming(false) } + const startRename = (): void => { + setName(folder.name) + setRenaming(true) + } return (
-
+
{ + e.preventDefault() + setMenu(menuPoint(e)) + }} + >
+ {menu ? ( + setMenu(null)} + items={[ + { label: 'Rename', icon: , onSelect: startRename }, + { + label: folder.collapsed ? 'Expand' : 'Collapse', + icon: , + onSelect: () => actions.toggleFolder(folder.id) + }, + { + label: 'New folder', + icon: , + disabled: folderCount >= 32, + onSelect: () => void actions.createFolder(`Folder ${folderCount + 1}`) + }, + { + label: 'Delete folder (accounts stay)', + icon: , + danger: true, + onSelect: () => actions.deleteFolder(folder.id) + } + ]} + /> + ) : null} {folder.collapsed ? null : (
{accounts.length === 0 ?
Drop accounts here.
: null} diff --git a/src/renderer/styles/base.css b/src/renderer/styles/base.css index 023c9be..d3ba830 100644 --- a/src/renderer/styles/base.css +++ b/src/renderer/styles/base.css @@ -2031,3 +2031,112 @@ kbd, flex: 1; min-width: 0; } + +/* ---- Sidebar motion + right-click menus ------------------------------------------ */ + +/* Collapse/expand slides the edge; the content keeps its own width, so it is revealed or + clipped instead of reflowing, and the newly shown content fades in. */ +.side { + transition: width 220ms cubic-bezier(0.22, 1, 0.36, 1); +} +.side.moving { + overflow: hidden; +} +.side.moving:not(.col) > * { + min-width: 235px; +} +.side.col { + align-items: stretch; +} +/* The header (its divider) spans the moving edge; the rest stays in the 51px rail. */ +.side.col > :not(.side-hd) { + width: 51px; +} +.side > :not(.side-hd) { + animation: side-fade 200ms ease-out; +} +@keyframes side-fade { + from { + opacity: 0; + } +} + +.ws-row.menu { + background: var(--raise); + color: var(--tx); +} +.ws-in { + flex: 1; + min-width: 0; + height: 24px; + font-size: 13px; +} + +.cm-scrim { + position: fixed; + inset: 0; + z-index: 40; +} +.cm { + position: fixed; + z-index: 41; + min-width: 190px; + padding: 4px; + display: flex; + flex-direction: column; + border: 1px solid var(--line2); + border-radius: 9px; + background: var(--panel); + box-shadow: 0 12px 32px -8px rgba(0, 0, 0, 0.5); + animation: cm-in 110ms ease-out; + transform-origin: top left; +} +@keyframes cm-in { + from { + opacity: 0; + transform: scale(0.97); + } +} +.cm-it { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px 6px 6px; + border: 0; + border-radius: 6px; + background: none; + color: var(--tx); + font-size: 12.5px; + text-align: left; + white-space: nowrap; +} +.cm-it:hover:not(:disabled), +.cm-it:focus { + background: var(--raise); + outline: none; +} +.cm-it:disabled { + color: var(--fa); +} +.cm-it.danger:not(:disabled) { + color: var(--err); +} +.cm-ic { + width: 16px; + display: grid; + place-items: center; + color: var(--mu); +} +.cm-it.danger .cm-ic { + color: inherit; +} + +@media (prefers-reduced-motion: reduce) { + .side { + transition: none; + } + .side > *, + .cm { + animation: none; + } +} diff --git a/tests/e2e/sidebar-menus.spec.ts b/tests/e2e/sidebar-menus.spec.ts new file mode 100644 index 0000000..554554e --- /dev/null +++ b/tests/e2e/sidebar-menus.spec.ts @@ -0,0 +1,91 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { _electron as electron, expect, test } from '@playwright/test' + +const ROOT = path.resolve(__dirname, '../..') +const MAIN = path.join(ROOT, 'out/main/index.js') + +test('right-click edits folders and workspaces; the sidebar collapses smoothly', async () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wraithgrid-menus-e2e-')) + const home = path.join(tmp, 'home') + const userData = path.join(tmp, 'userdata') + fs.mkdirSync(home, { recursive: true }) + fs.mkdirSync(userData, { recursive: true }) + const cfgFile = path.join(userData, 'config.json') + fs.writeFileSync( + cfgFile, + JSON.stringify({ + version: 1, + claudePath: '', + accounts: [], + accountFolders: [{ id: 'f-one', name: 'Folder 1', collapsed: false }], + workspaces: [ + { id: 'ws-a', name: 'alpha', panes: [], layout: null }, + { id: 'ws-b', name: 'beta', panes: [], layout: null } + ], + activeWorkspace: 'ws-a', + recentFolders: [], + settings: { sharedMode: 'per-account' } + }) + ) + const saved = () => JSON.parse(fs.readFileSync(cfgFile, 'utf8')) + + const app = await electron.launch({ + args: [MAIN], + env: { ...process.env, HOME: home, WRAITHGRID_USER_DATA_DIR: userData } + }) + try { + const win = await app.firstWindow() + const side = win.getByRole('complementary', { name: 'Sidebar' }) + + // Folder: rename from the menu. + await side.locator('.fold-hd', { hasText: 'Folder 1' }).click({ button: 'right' }) + const folderMenu = win.getByRole('menu', { name: 'Folder Folder 1' }) + await expect(folderMenu.getByRole('menuitem').first()).toBeFocused() + await folderMenu.getByRole('menuitem', { name: 'Rename' }).click() + await side.getByLabel('Folder name').fill('Clients') + await side.getByLabel('Folder name').press('Enter') + await expect.poll(() => saved().accountFolders[0].name).toBe('Clients') + + // Escape closes a menu without doing anything. + await side.locator('.fold-hd', { hasText: 'Clients' }).click({ button: 'right' }) + await win.keyboard.press('Escape') + await expect(win.getByRole('menu')).toHaveCount(0) + + // Workspace: rename, change icon, then a two-step delete. + const beta = side.locator('.ws-row', { hasText: 'beta' }) + await beta.click({ button: 'right' }) + await win.getByRole('menuitem', { name: 'Rename' }).click() + await side.getByLabel('Workspace name').fill('review') + await side.getByLabel('Workspace name').press('Enter') + await expect.poll(() => saved().workspaces[1].name).toBe('review') + + const review = side.locator('.ws-row', { hasText: 'review' }) + await review.click({ button: 'right' }) + await win.getByRole('menuitem', { name: 'Change icon' }).click() + await win.getByRole('button', { name: 'Use 🚀' }).click() + await expect.poll(() => saved().workspaces[1].icon).toBe('🚀') + + await review.click({ button: 'right' }) + await win.getByRole('menuitem', { name: 'Delete workspace' }).click() + // First click only arms it. + expect(saved().workspaces).toHaveLength(2) + await win.getByRole('menuitem', { name: 'Click again to delete' }).click() + await expect.poll(() => saved().workspaces.length).toBe(1) + + // The last workspace can't be deleted. + await side.locator('.ws-row', { hasText: 'alpha' }).click({ button: 'right' }) + await expect(win.getByRole('menuitem', { name: 'Delete (last workspace)' })).toBeDisabled() + await win.keyboard.press('Escape') + + // Collapse animates the width instead of jumping, then settles at the rail width. + await side.getByRole('button', { name: 'Collapse sidebar' }).click() + const midWidth = await side.evaluate((el) => el.getBoundingClientRect().width) + expect(midWidth).toBeGreaterThan(52) + await expect.poll(() => side.evaluate((el) => el.getBoundingClientRect().width)).toBe(52) + await expect(side).not.toHaveClass(/moving/) + } finally { + await app.close() + } +})