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..d12a846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 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) - [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 16be15f..4fa0689 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, @@ -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 @@ -40,11 +41,11 @@ 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 -- `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/` @@ -76,6 +77,9 @@ 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`. +- 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 -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). diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d240ade..c2af093 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -20,4 +20,8 @@ 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 | 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/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/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/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/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/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/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/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() + } +}) 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 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) + }) +})