From dea21510e6c449b54f143931b0b4c7d65148d88e Mon Sep 17 00:00:00 2001 From: cachewraith Date: Wed, 30 Sep 2026 09:59:22 +0700 Subject: [PATCH] feat: share ~/.claude across accounts, add folders, icons and release alerts Sharing is now two modes, "Overall (~/.claude)" (the default) and "Each account its own". Overall links CLAUDE.md, settings.json, skills/, plugins/, agents/ and commands/ from the machine's ~/.claude into every account, including accounts added later. The user's real CLAUDE.md and plugins live there, not in a Wraithgrid account. settings.json is shared too, because an installed plugin only loads when settings.json enables it. A stale link pointing elsewhere is replaced without a backup (links hold no data); real files are still renamed to .wraithgrid-backup. The old per-source-account setting is dropped and existing links move over on the next pane start. Account dirs have their shared links removed before a recursive delete. Changing the mode offers a "Restart claude panes" button rather than restarting on its own, since a restart ends the running conversation. Terminal: Shift+Enter sends ESC CR in claude panes, which the CLI reads as a new line. Ctrl+= / Ctrl++ / Ctrl+- / Ctrl+0 change the font size. Ctrl+Shift+- still reaches claude as its undo key. Sidebar accounts can be grouped into one-level folders and moved with drag and drop (dnd-kit, as the pane grid already uses). Accounts and workspaces can each take an emoji icon; without one they show a letter badge, so every row lines up. Icons are rendered as text only and are length-limited in the schema. Old configs load with no folders or icons. Running panes show a spinner and a sheen on the account strip. Both are off under prefers-reduced-motion. A newer GitHub release raises an OS notification once per version. It fires on the launch check, and every 6 hours while the app stays open, in packaged builds only. The last announced version is stored in userData/update-notified.json, not config.json, because the renderer rewrites config.json whole. Clicking opens the release page, and only URLs under the project's releases path are allowed. Windows needs an AppUserModelID for toasts, so one is set. UI clean-up: one header per sidebar section, workspace shortcut digits shown on hover, only "login needed" shown as account status, a balanced Accounts table, a wrapped claude version line, untruncated font names and shorter sharing copy. The icon picker returns focus after a pick, so Esc still closes the workspace switcher. Known open item: a report that the Claude mascot does not animate in panes still needs a repro (see docs/TODO.md). --- CHANGELOG.md | 10 + docs/ARCHITECTURE.md | 3 +- docs/CONTEXT.md | 10 +- docs/DECISIONS.md | 7 + docs/TODO.md | 2 +- src/main/index.ts | 49 +++- src/main/ipc.ts | 50 ++-- src/main/shared-config.ts | 51 ++-- src/main/update-notify.ts | 51 ++++ src/preload/index.ts | 2 +- src/renderer/app/App.tsx | 11 + src/renderer/app/shortcuts.ts | 15 +- src/renderer/app/store.ts | 113 +++++++- src/renderer/components/AccountIcon.tsx | 45 ++++ src/renderer/components/AccountsView.tsx | 26 +- src/renderer/components/IconPicker.tsx | 104 ++++++++ src/renderer/components/NewPaneDialog.tsx | 6 +- src/renderer/components/Pane.tsx | 6 +- src/renderer/components/PaneHeader.tsx | 11 +- src/renderer/components/SettingsView.tsx | 73 +++--- src/renderer/components/ShortcutsSheet.tsx | 2 + src/renderer/components/Sidebar.tsx | 39 ++- src/renderer/components/SidebarAccounts.tsx | 188 ++++++++++++++ src/renderer/components/Terminal.tsx | 21 ++ src/renderer/components/WorkspaceSwitcher.tsx | 8 + src/renderer/styles/base.css | 243 +++++++++++++++++- src/shared/ipc-contract.ts | 6 +- src/shared/schema.ts | 35 ++- src/shared/types.ts | 53 +++- tests/e2e/account-folders.spec.ts | 87 +++++++ tests/e2e/shared.spec.ts | 20 +- tests/unit/config.test.ts | 12 +- tests/unit/shared-config.test.ts | 35 +-- tests/unit/shortcuts.test.ts | 11 + tests/unit/update-notify.test.ts | 67 +++++ 35 files changed, 1285 insertions(+), 187 deletions(-) create mode 100644 src/main/update-notify.ts create mode 100644 src/renderer/components/AccountIcon.tsx create mode 100644 src/renderer/components/IconPicker.tsx create mode 100644 src/renderer/components/SidebarAccounts.tsx create mode 100644 tests/e2e/account-folders.spec.ts create mode 100644 tests/unit/update-notify.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 238ac51..59ec339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## 2026-09-30 +- [ui] Clean-up pass: letter/emoji badges (`Avatar`) for every account and workspace, one header per sidebar section, workspace shortcut digits on hover, only "login needed" shown as account status, balanced Accounts table, wrapped claude version, untruncated font names, shorter sharing copy; icon picker returns focus after a pick (files: src/renderer/components/{AccountIcon,IconPicker,Sidebar,SidebarAccounts,AccountsView,SettingsView,NewPaneDialog,PaneHeader}.tsx, src/renderer/styles/base.css) +- [workspaces] Custom emoji icon per workspace, set from the workspace switcher (files: src/shared/{types,schema}.ts, src/renderer/app/store.ts, src/renderer/components/WorkspaceSwitcher.tsx, tests/e2e/account-folders.spec.ts) +- [updates] A new release raises an OS notification once per version (launch check + every 6 h while open, packaged builds only); click opens the release page (files: src/main/update-notify.ts, src/main/index.ts, src/main/ipc.ts, tests/unit/update-notify.test.ts) +- [sidebar] Account folders (create, rename, collapse, delete) with drag-and-drop of accounts into/out of them (files: src/renderer/components/{SidebarAccounts,Sidebar}.tsx, src/shared/{types,schema}.ts, src/renderer/app/store.ts, tests/e2e/account-folders.spec.ts, tests/unit/config.test.ts) +- [accounts] Custom emoji icon per account, picked on the Accounts page, shown in the sidebar and pane header (files: src/renderer/components/{AccountIcon,AccountsView,PaneHeader,Pane}.tsx) +- [panes] Running panes animate: spinner status dot and a sheen on the account strip; off under reduced motion (files: src/renderer/components/Pane.tsx, src/renderer/styles/base.css) +- [shared] Sharing is now two modes, "Overall (~/.claude)" (default) and "Each account its own". Overall links CLAUDE.md, settings.json, skills/, plugins/, agents/ and commands/ from ~/.claude into every account, including ones added later. Adds a "Restart claude panes" button (files: src/main/shared-config.ts, src/main/ipc.ts, src/shared/{types,schema,ipc-contract}.ts, src/preload/index.ts, src/renderer/app/store.ts, src/renderer/components/{SettingsView,AccountsView}.tsx, tests/unit/shared-config.test.ts, tests/e2e/shared.spec.ts) +- [terminal] Shift+Enter inserts a new line in claude panes (sends ESC CR) (files: src/renderer/components/Terminal.tsx) +- [shortcuts] Ctrl+= / Ctrl++ / Ctrl+- / Ctrl+0 change the terminal font size (files: src/renderer/app/{shortcuts.ts,App.tsx}, src/renderer/components/ShortcutsSheet.tsx, tests/unit/shortcuts.test.ts) + - [ci] Build installers only on release tags; push/PR runs checks only, docs-only changes skip CI (files: .github/workflows/build.yml, .github/workflows/release.yml) - [docs] Add the AI Working Agreement and context files (files: CLAUDE.md, CHANGELOG.md, docs/CONTEXT.md, docs/ARCHITECTURE.md, docs/DECISIONS.md, docs/TODO.md) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4e066fc..432f275 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,8 +16,9 @@ | `config-store.ts` | `ConfigStore`: parse/migrate/sanitize, atomic write, quarantine to `config.bad-.json` | | `claude-detect.ts` | Locate `claude` on PATH or via override; read version | | `platform.ts` | Desktop/window-chrome detection, default shell, extra bin dirs, spawn command, login-shell PATH | -| `shared-config.ts` | Symlink/junction/hardlink shared CLAUDE.md + skills; backups, never deletes | +| `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 | | `paths.ts` | Config file path, accounts root, `~` resolution, `isStrictlyInside` guard | ## Renderer diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 655abd7..16be15f 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -27,8 +27,9 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e - `src/main/config-store.ts`: config.json load/save, atomic write, bad-file quarantine - `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 one CLAUDE.md + skills/ into every account +- `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-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 - `src/shared/ipc-channels.ts`: the complete IPC channel list @@ -39,7 +40,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) +- `src/renderer/components/`: UI (PaneGrid, Pane, Terminal, dialogs, Settings/Accounts views; SidebarAccounts = folders + drag-drop; AccountIcon/Avatar = badges; IconPicker) - `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 @@ -70,8 +71,11 @@ react-resizable-panels, @dnd-kit. Tests: Vitest (unit), Playwright (Electron e2e - Sandboxed preload can't require node_modules: preload is bundled (`externalizeDeps: false`). - Single-instance lock: a second launch exits. - Tests use `WRAITHGRID_USER_DATA_DIR` for a throwaway config dir. +- dnd-kit measures drop zones async after drag start: e2e drags must move in paced steps. - CI skips pushes touching only `*.md`/`docs/**`; installers build only on a `v*.*.*` tag. - 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. ## Current focus -v1.1.0 released (appearance settings, update check, Windows + distro packages). No active task. +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). diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 7c97e7e..d240ade 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -14,3 +14,10 @@ Entries dated 2026-09-29 are reconstructed from the code and commit history duri - 2026-09-29 | Platform logic as pure functions of `(platform, env)` | Unit-testable on any OS | Branching on `process.platform` inline - 2026-09-30 | Adopt the AI Working Agreement (`CLAUDE.md`) + docs context files | Cut per-session re-exploration | None - 2026-09-30 | CI builds installers only for release tags; docs-only pushes skip CI | Avoid rebuilding downloads on every push/doc edit | Separate package workflow (more files); manual workflow_dispatch packaging (not needed) +- 2026-09-30 | Sharing is two modes: "overall" links ~/.claude into every account (default), "per-account" unlinks | Users want their normal CLAUDE.md, skills and plugins in every account without picking a source account | Picking a source account (old design); copying files into each account (drifts) +- 2026-09-30 | Share settings.json and plugins/ too, not only CLAUDE.md and skills/ | Installed plugins only load when enabled in settings.json | Sharing only plugins/ (plugins stay disabled) +- 2026-09-30 | A link in the way that points elsewhere is removed, not backed up | Links hold no data; lets old source-account links move to ~/.claude | Backing up links (restores stale links later) +- 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 | 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 023883f..e5fddee 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,7 +1,7 @@ # TODO ## Now -- (none) +- Claude mascot ("pet") not animating in panes: needs a repro from the user (which animation, when) ## Next - (none recorded) diff --git a/src/main/index.ts b/src/main/index.ts index 13e61d5..b5df9c6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,6 +1,6 @@ import os from 'node:os' import path from 'node:path' -import { app, BrowserWindow, Menu, nativeTheme, net, session, shell } from 'electron' +import { app, BrowserWindow, Menu, nativeTheme, net, Notification, session, shell } from 'electron' import { spawn } from 'node-pty' import { IPC } from '@shared/ipc-channels' import type { PtyDataEvent, PtyExitEvent } from '@shared/ipc-contract' @@ -18,6 +18,7 @@ import { } from './platform' import { PtyManager } from './pty-manager' import { UpdateChecker } from './update-check' +import { notifyIfNew, type UpdateNotice } from './update-notify' // Requirements §9 names ~/.config/wraithgrid; Electron would default to the productName. // WRAITHGRID_USER_DATA_DIR lets tests run against a throwaway config dir. @@ -26,6 +27,12 @@ app.setPath( process.env.WRAITHGRID_USER_DATA_DIR || path.join(app.getPath('appData'), 'wraithgrid') ) +// Windows only shows toasts for an app with an AppUserModelID (matches electron-builder appId). +if (process.platform === 'win32') app.setAppUserModelId('dev.cachewraith.wraithgrid') + +/** While the app stays open, look for a new release this often (GitHub allows 60/h). */ +const UPDATE_RECHECK_MS = 6 * 60 * 60 * 1000 + // Two instances would overwrite each other's config; focus the first one instead. if (!app.requestSingleInstanceLock()) { app.exit(0) @@ -175,8 +182,42 @@ app.on('second-instance', () => { mainWindow.focus() }) +/** A native notification; clicking it opens the release page and brings the window back. */ +function showUpdateNotice(notice: UpdateNotice): void { + if (!Notification.isSupported()) return + const n = new Notification({ + title: notice.title, + body: notice.body, + icon: path.join(__dirname, '../../build/icon.png') + }) + n.on('click', () => { + void shell.openExternal(notice.url) + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.focus() + } + }) + n.show() +} + void app.whenReady().then(() => { store.load() + const updates = new UpdateChecker({ + currentVersion: app.getVersion(), + fetch: (url, init) => net.fetch(url, init) + }) + const notifyDeps = { + stateFile: path.join(app.getPath('userData'), 'update-notified.json'), + show: showUpdateNotice + } + const notify = (r: Parameters[0]): void => + void notifyIfNew(r, notifyDeps).catch(() => {}) + if (app.isPackaged) { + setInterval(() => { + if (!store.get().settings.checkUpdatesOnLaunch) return + void updates.check().then(notify) + }, UPDATE_RECHECK_MS).unref() + } Menu.setApplicationMenu(null) hardenSessions() registerIpc({ @@ -186,10 +227,8 @@ void app.whenReady().then(() => { homeDir, desktop, envReady, - updates: new UpdateChecker({ - currentVersion: app.getVersion(), - fetch: (url, init) => net.fetch(url, init) - }), + updates, + onLaunchUpdateCheck: notify, isPackaged: app.isPackaged, onConfigChanged: (prev, next) => { const dark = nativeTheme.shouldUseDarkColors diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 317198e..c474f18 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -39,7 +39,13 @@ import type { ConfigStore } from './config-store' import { buildPaneEnv } from './pane-env' import { accountsRoot, isStrictlyInside, resolveUserPath } from './paths' import { resolveDefaultShell, spawnCommandFor, type DesktopInfo } from './platform' -import { ensureSource, linkShared, unlinkShared, type ItemResult } from './shared-config' +import { + ensureSource, + linkShared, + overallSourceDir, + unlinkShared, + type ItemResult +} from './shared-config' import type { PtyManager } from './pty-manager' import type { UpdateChecker } from './update-check' @@ -54,6 +60,8 @@ export interface IpcDeps { updates: UpdateChecker /** Launch-time update checks only run in packaged builds. */ isPackaged: boolean + /** Sees every launch-time check result (e.g. to raise an OS notification). */ + onLaunchUpdateCheck?: (result: UpdateCheckResult) => void /** Called after a valid config replaced the old one. */ onConfigChanged?: (prev: Config, next: Config) => void } @@ -114,15 +122,16 @@ export function registerIpc(deps: IpcDeps): void { const fail = (error: string): { ok: false; error: string } => ({ ok: false, error }) const dirOf = (acc: { configDir: string }): string => resolveUserPath(acc.configDir, homeDir) - /** Links the shared CLAUDE.md and skills into one account, if sharing is on. */ + const sharedSource = overallSourceDir(homeDir) + + /** Links the machine's CLAUDE.md, settings, skills and plugins into one account, if sharing is overall. */ const syncShared = async (accountId: string): Promise => { const cfg = store.get() - const source = cfg.accounts.find((x) => x.id === cfg.settings.sharedSourceAccountId) const account = cfg.accounts.find((x) => x.id === accountId) - if (!source || !account || source.id === account.id) return + if (cfg.settings.sharedMode !== 'overall' || !account) return try { - await ensureSource(dirOf(source)) - const failed = (await linkShared(dirOf(source), dirOf(account))).filter( + await ensureSource(sharedSource) + const failed = (await linkShared(sharedSource, dirOf(account))).filter( (r) => r.outcome === 'failed' ) failed.forEach((r) => log(`could not link shared ${r.item} for ${account.name}: ${r.error}`)) @@ -292,6 +301,8 @@ export function registerIpc(deps: IpcDeps): void { const realHome = await realpath(homeDir) if (!isStrictlyInside(real, realHome)) return fail('Refusing to delete a folder outside your home directory') + // Drop the links to ~/.claude first so a recursive delete can never reach through them. + await unlinkShared(sharedSource, real) await rm(real, { recursive: true, force: true }) log(`deleted account config dir ${contractHome(target, homeDir)} at the user's request`) return { ok: true } @@ -306,29 +317,21 @@ export function registerIpc(deps: IpcDeps): void { sharedApplyArgs, async (a): Promise => { const cfg = store.get() - const next = a.sourceAccountId - ? cfg.accounts.find((x) => x.id === a.sourceAccountId) - : undefined - if (a.sourceAccountId && !next) return fail('That account no longer exists') - const prev = cfg.accounts.find((x) => x.id === cfg.settings.sharedSourceAccountId) const report: SharedReportEntry[] = [] const add = (account: string, results: ItemResult[]): void => { results.forEach((r) => report.push({ account, ...r })) } - // Switching or stopping: take the old links out first, restoring what they replaced. - if (prev && prev.id !== next?.id) { - for (const acc of cfg.accounts) add(acc.name, await unlinkShared(dirOf(prev), dirOf(acc))) - } - if (next) { - await ensureSource(dirOf(next)) - for (const acc of cfg.accounts) { - if (acc.id !== next.id) add(acc.name, await linkShared(dirOf(next), dirOf(acc))) - } + if (a.mode === 'overall') { + await ensureSource(sharedSource) + for (const acc of cfg.accounts) add(acc.name, await linkShared(sharedSource, dirOf(acc))) + } else { + // Take the links out, restoring what they replaced. + for (const acc of cfg.accounts) add(acc.name, await unlinkShared(sharedSource, dirOf(acc))) } log( - next - ? `sharing CLAUDE.md and skills from ${next.name} with ${cfg.accounts.length - 1} accounts` - : 'stopped sharing CLAUDE.md and skills' + a.mode === 'overall' + ? `sharing ~/.claude with ${cfg.accounts.length} accounts` + : 'stopped sharing ~/.claude' ) return { ok: true, report } }, @@ -366,6 +369,7 @@ export function registerIpc(deps: IpcDeps): void { } const r = await deps.updates.check() if (r.status === 'error') log(`update check failed: ${r.error}`) + if (a.reason === 'launch') deps.onLaunchUpdateCheck?.(r) return r }, { diff --git a/src/main/shared-config.ts b/src/main/shared-config.ts index d73f289..63a93bd 100644 --- a/src/main/shared-config.ts +++ b/src/main/shared-config.ts @@ -1,16 +1,27 @@ -// One CLAUDE.md and one skills folder for every account. The source account keeps the -// real files; every other account's config dir gets links to them. Nothing inside a -// config dir is ever read: only link metadata is inspected, and anything in the way is -// renamed to a backup, never deleted. +// "Overall" sharing: every account uses the machine's own claude setup (~/.claude) for +// CLAUDE.md, settings, skills, plugins, agents and commands. The source keeps the real +// files; each account's config dir gets links to them. Logins (.credentials.json, +// .claude.json) and history stay per account. Nothing inside a config dir is ever read: +// only link metadata is inspected, and a real file in the way is renamed to a backup, +// never deleted. import type { Stats } from 'node:fs' import fs from 'node:fs/promises' import path from 'node:path' export const SHARED_ITEMS = [ - { name: 'CLAUDE.md', kind: 'file' }, - { name: 'skills', kind: 'dir' } + { name: 'CLAUDE.md', kind: 'file', initial: '' }, + { name: 'settings.json', kind: 'file', initial: '{}\n' }, + { name: 'skills', kind: 'dir' }, + { name: 'plugins', kind: 'dir' }, + { name: 'agents', kind: 'dir' }, + { name: 'commands', kind: 'dir' } ] as const +/** The machine's default claude config dir: the source of "overall" sharing. */ +export function overallSourceDir(homeDir: string): string { + return path.join(homeDir, '.claude') +} + export type LinkOutcome = 'linked' | 'already-linked' | 'unlinked' | 'restored' | 'failed' export interface ItemResult { @@ -58,15 +69,21 @@ async function backupPathFor(p: string, now: Date): Promise { return `${plain}-${now.toISOString().replace(/[:.]/g, '-')}` } -/** Makes sure the source has something to link to (an empty CLAUDE.md, an empty skills/). */ +/** Makes sure the source has something to link to for every item. Never overwrites. */ export async function ensureSource(sourceDir: string): Promise { - await fs.mkdir(path.join(sourceDir, 'skills'), { recursive: true, mode: 0o700 }) - try { - // 'wx' creates the file only if it does not exist; an existing CLAUDE.md is untouched. - const handle = await fs.open(path.join(sourceDir, 'CLAUDE.md'), 'wx', 0o600) - await handle.close() - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err + await fs.mkdir(sourceDir, { recursive: true, mode: 0o700 }) + for (const item of SHARED_ITEMS) { + const p = path.join(sourceDir, item.name) + if (item.kind === 'dir') { + await fs.mkdir(p, { recursive: true, mode: 0o700 }) + continue + } + try { + // 'wx' creates the file only if it does not exist; an existing file is untouched. + await fs.writeFile(p, item.initial, { flag: 'wx', mode: 0o600 }) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err + } } } @@ -108,7 +125,11 @@ export async function linkShared( continue } let backup: string | undefined - if (await lstatOrNull(dst)) { + const existing = await lstatOrNull(dst) + if (existing?.isSymbolicLink()) { + // A link to somewhere else (e.g. an older source) holds no data of its own. + await fs.unlink(dst) + } else if (existing) { backup = await backupPathFor(dst, now()) await fs.rename(dst, backup) } diff --git a/src/main/update-notify.ts b/src/main/update-notify.ts new file mode 100644 index 0000000..e374397 --- /dev/null +++ b/src/main/update-notify.ts @@ -0,0 +1,51 @@ +// Tells the user about a new release through the OS notification area, once per version. +// The last announced version lives in a small file in userData, apart from config.json, +// which the renderer owns and rewrites whole. +import fs from 'node:fs/promises' +import type { UpdateCheckResult } from '@shared/ipc-contract' +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. */ + url: string +} + +export interface NotifyDeps { + stateFile: string + show(notice: UpdateNotice): void +} + +const RELEASE_PAGE_PREFIX = `https://github.com/${RELEASES_REPO}/releases/` + +async function lastNotified(stateFile: string): Promise { + try { + const raw: unknown = JSON.parse(await fs.readFile(stateFile, 'utf8')) + const v = (raw as { lastNotified?: unknown } | null)?.lastNotified + return typeof v === 'string' ? v : null + } catch { + return null + } +} + +/** Shows a notice when `result` is a newer release not announced before. True if shown. */ +export async function notifyIfNew(result: UpdateCheckResult, deps: NotifyDeps): Promise { + if (result.status !== 'available') return false + const { version, url } = result.latest + if (!url.startsWith(RELEASE_PAGE_PREFIX)) return false + 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.`, + url + }) + try { + await fs.writeFile(deps.stateFile, JSON.stringify({ lastNotified: version }) + '\n', { + mode: 0o600 + }) + } catch { + // Worst case the same release is announced again next launch. + } + return true +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 8cd2fdb..7f8f21f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -42,7 +42,7 @@ const api: WraithApi = { openExternal: (url) => ipcRenderer.invoke(IPC.shellOpenExternal, { url }) }, shared: { - apply: (sourceAccountId) => ipcRenderer.invoke(IPC.sharedApply, { sourceAccountId }) + apply: (mode) => ipcRenderer.invoke(IPC.sharedApply, { mode }) }, app: { info: () => ipcRenderer.invoke(IPC.appInfo) diff --git a/src/renderer/app/App.tsx b/src/renderer/app/App.tsx index 2fa1ced..75627e0 100644 --- a/src/renderer/app/App.tsx +++ b/src/renderer/app/App.tsx @@ -14,6 +14,7 @@ import { matchShortcut } from './shortcuts' import { paneIds } from '../layout/tree' import { terminalTheme } from '../lib/term-theme' import { DENSE_PANE_COUNT, activeWorkspace, currentTheme } from './store' +import { FONT_SIZE_DEFAULT, FONT_SIZE_MAX, FONT_SIZE_MIN } from '@shared/types' /** App shortcuts, caught in the capture phase so a focused terminal never sees them. */ function useGlobalShortcuts(): void { @@ -47,6 +48,16 @@ function useGlobalShortcuts(): void { if (s.modal) s.closeModal() else s.openModal({ kind: 'shortcuts' }) break + case 'fontSize': { + const size = s.config.settings.fontSize + s.updateSettings({ + fontSize: + action.delta === 0 + ? FONT_SIZE_DEFAULT + : Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, size + action.delta)) + }) + break + } } } window.addEventListener('keydown', onKey, true) diff --git a/src/renderer/app/shortcuts.ts b/src/renderer/app/shortcuts.ts index bf3b985..2149d5d 100644 --- a/src/renderer/app/shortcuts.ts +++ b/src/renderer/app/shortcuts.ts @@ -7,6 +7,8 @@ export type ShortcutAction = | { type: 'focus'; dir: Direction } | { type: 'workspace'; index: number } | { type: 'shortcuts' } + /** +1 / -1 steps the terminal font size; 0 resets it. */ + | { type: 'fontSize'; delta: -1 | 0 | 1 } export interface KeyLike { key: string @@ -34,6 +36,16 @@ export function matchShortcut(e: KeyLike): ShortcutAction | null { const dir = ARROWS[e.key] return dir ? { type: 'focus', dir } : null } + // Ctrl+= / Ctrl++ / Ctrl+- / Ctrl+0, like a browser. Ctrl+Shift+- stays with the + // terminal: it is claude's undo (Ctrl+_). + if (e.ctrlKey && !e.altKey) { + if (e.code === 'Equal' || e.code === 'NumpadAdd' || e.key === '+') + return { type: 'fontSize', delta: 1 } + if (!e.shiftKey && (e.code === 'Minus' || e.code === 'NumpadSubtract')) + return { type: 'fontSize', delta: -1 } + if (!e.shiftKey && (e.code === 'Digit0' || e.code === 'Numpad0')) + return { type: 'fontSize', delta: 0 } + } if (e.ctrlKey && e.shiftKey && !e.altKey) { switch (e.code) { case 'KeyN': @@ -57,5 +69,6 @@ export const SHORTCUT_HINT = { zoom: 'Ctrl+Shift+Z', focus: 'Ctrl+Alt+Arrow', workspace: 'Ctrl+Shift+1…9', - shortcuts: 'Ctrl+Shift+/' + shortcuts: 'Ctrl+Shift+/', + fontSize: 'Ctrl+= / Ctrl+- / Ctrl+0' } as const diff --git a/src/renderer/app/store.ts b/src/renderer/app/store.ts index 6f20d47..18c8211 100644 --- a/src/renderer/app/store.ts +++ b/src/renderer/app/store.ts @@ -11,6 +11,7 @@ import { baseName, slugify } from '@shared/paths' import { defaultConfig } from '@shared/schema' import { ACCOUNT_COLORS, + ACCOUNT_ICON_MAX, type Account, type Config, type LayoutNode, @@ -18,6 +19,7 @@ import { type PaneStatus, resolveTheme, type Settings, + type SharedMode, type ThemeName, type Workspace } from '@shared/types' @@ -123,16 +125,29 @@ export interface AppState { switchWorkspaceIndex(index: number): void createWorkspace(name: string): void renameWorkspace(id: string, name: string): void + /** An emoji, or '' for the first letter. */ + setWorkspaceIcon(id: string, icon: string): void deleteWorkspace(id: string): void // accounts addAccount(name: string, color: string): Promise importAccount(dir: string): string | null renameAccount(id: string, name: string): void + /** An emoji, or '' for the color dot. */ + setAccountIcon(id: string, icon: string): void + /** null moves the account out of any folder. */ + moveAccountToFolder(id: string, folderId: string | null): void + createFolder(name: string): string | null + renameFolder(id: string, name: string): void + toggleFolder(id: string): void + /** The folder's accounts move to the top level; none are removed. */ + deleteFolder(id: string): void removeAccount(id: string, deleteDir: boolean): Promise markSignedIn(id: string): void - /** Shares this account's CLAUDE.md and skills with all others (null: each keeps its own). */ - setSharedSource(id: string | null): Promise + /** 'overall' links ~/.claude into every account; 'per-account' takes the links out. */ + setSharedMode(mode: SharedMode): Promise + /** Restarts every started claude pane, e.g. so a new CLAUDE.md or skill is picked up. */ + restartClaudePanes(): number loginAccount(id: string): void // settings @@ -582,7 +597,7 @@ export function createAppStore({ createWorkspace(name) { const trimmed = name.trim().slice(0, 64) if (!trimmed) return - const ws: Workspace = { id: newId('ws'), name: trimmed, panes: [], layout: null } + const ws: Workspace = { id: newId('ws'), name: trimmed, icon: '', panes: [], layout: null } const s = get() set({ config: { ...s.config, workspaces: [...s.config.workspaces, ws] } }) get().switchWorkspace(ws.id) @@ -627,7 +642,9 @@ export function createAppStore({ configDir: res.dir, color, signedIn: false, - imported: false + imported: false, + icon: '', + folderId: null } get().updateConfig((c) => ({ ...c, @@ -652,7 +669,9 @@ export function createAppStore({ configDir: trimmed, color: nextFreeColor(s.config.accounts), signedIn: true, - imported: true + imported: true, + icon: '', + folderId: null } get().updateConfig((c) => ({ ...c, @@ -662,6 +681,69 @@ export function createAppStore({ return null }, + setWorkspaceIcon(id, icon) { + const trimmed = icon.trim().slice(0, ACCOUNT_ICON_MAX) + get().updateConfig((c) => ({ + ...c, + workspaces: c.workspaces.map((w) => (w.id === id ? { ...w, icon: trimmed } : w)) + })) + }, + + setAccountIcon(id, icon) { + const trimmed = icon.trim().slice(0, ACCOUNT_ICON_MAX) + get().updateConfig((c) => ({ + ...c, + accounts: c.accounts.map((a) => (a.id === id ? { ...a, icon: trimmed } : a)) + })) + }, + + moveAccountToFolder(id, folderId) { + get().updateConfig((c) => { + if (folderId && !c.accountFolders.some((f) => f.id === folderId)) return c + return { + ...c, + accounts: c.accounts.map((a) => (a.id === id ? { ...a, folderId } : a)) + } + }) + }, + + createFolder(name) { + const trimmed = name.trim().slice(0, 64) + if (!trimmed || get().config.accountFolders.length >= 32) return null + const id = newId('f') + get().updateConfig((c) => ({ + ...c, + accountFolders: [...c.accountFolders, { id, name: trimmed, collapsed: false }] + })) + return id + }, + + renameFolder(id, name) { + const trimmed = name.trim().slice(0, 64) + if (!trimmed) return + get().updateConfig((c) => ({ + ...c, + accountFolders: c.accountFolders.map((f) => (f.id === id ? { ...f, name: trimmed } : f)) + })) + }, + + toggleFolder(id) { + get().updateConfig((c) => ({ + ...c, + accountFolders: c.accountFolders.map((f) => + f.id === id ? { ...f, collapsed: !f.collapsed } : f + ) + })) + }, + + deleteFolder(id) { + get().updateConfig((c) => ({ + ...c, + accountFolders: c.accountFolders.filter((f) => f.id !== id), + accounts: c.accounts.map((a) => (a.folderId === id ? { ...a, folderId: null } : a)) + })) + }, + renameAccount(id, name) { const trimmed = name.trim().slice(0, 64) if (!trimmed) return @@ -675,11 +757,6 @@ export function createAppStore({ const s = get() const account = accountById(s.config, id) if (!account) return null - // The other accounts must not keep links into a dir that is going away. - if (s.config.settings.sharedSourceAccountId === id) { - const err = await get().setSharedSource(null) - if (err) return err - } // Panes first: a running claude must not keep writing into a dir being deleted. s.config.workspaces .flatMap((w) => w.panes) @@ -701,14 +778,24 @@ export function createAppStore({ return null }, - async setSharedSource(id) { - const res = await api.shared.apply(id) + async setSharedMode(mode) { + const res = await api.shared.apply(mode) if (!res.ok) return res.error - get().updateSettings({ sharedSourceAccountId: id }) + get().updateSettings({ sharedMode: mode }) set({ sharedReport: res.report }) return null }, + restartClaudePanes() { + const s = get() + const ids = s.config.workspaces + .flatMap((w) => w.panes) + .filter((p) => !p.shell && s.runtime[p.id]?.requested) + .map((p) => p.id) + ids.forEach((id) => get().restartPane(id)) + return ids.length + }, + markSignedIn(id) { get().updateConfig((c) => ({ ...c, diff --git a/src/renderer/components/AccountIcon.tsx b/src/renderer/components/AccountIcon.tsx new file mode 100644 index 0000000..8e61436 --- /dev/null +++ b/src/renderer/components/AccountIcon.tsx @@ -0,0 +1,45 @@ +import type { Account } from '@shared/types' + +/** + * A square badge on a tint of `color`: the emoji when one is set, else the name's first + * letter. Accounts and workspaces use it so every row lines up the same way. + */ +export function Avatar({ + icon, + name, + color, + size = 18 +}: { + icon: string + name: string + color: string + size?: number +}) { + const letter = [...name.trim()][0]?.toUpperCase() ?? '?' + return ( + + ) +} + +export function AccountIcon({ + account, + size +}: { + account: Pick + size?: number +}) { + return +} diff --git a/src/renderer/components/AccountsView.tsx b/src/renderer/components/AccountsView.tsx index 728e3e9..2fd241d 100644 --- a/src/renderer/components/AccountsView.tsx +++ b/src/renderer/components/AccountsView.tsx @@ -3,6 +3,7 @@ import { contractHome, slugify } from '@shared/paths' import { ACCOUNT_COLORS, type Account } from '@shared/types' import { useActions, useApp, useServices } from '../app/services' import { nextFreeColor } from '../app/store' +import { IconPicker } from './IconPicker' import { Dialog } from './Dialog' import { IconCheck, @@ -170,14 +171,11 @@ function ImportAccountForm() { function AccountRow({ account, uses }: { account: Account; uses: number }) { const actions = useActions() - const sharedFrom = useApp((s) => - s.config.accounts.find((a) => a.id === s.config.settings.sharedSourceAccountId) + const sharedNote = useApp((s) => + s.config.settings.sharedMode === 'overall' + ? 'Uses the CLAUDE.md, settings, skills and plugins of ~/.claude' + : null ) - const sharedNote = !sharedFrom - ? null - : sharedFrom.id === account.id - ? 'Source of the shared CLAUDE.md and skills' - : `Uses the CLAUDE.md and skills of ${sharedFrom.name}` const [renaming, setRenaming] = useState(false) const [name, setName] = useState(account.name) @@ -193,7 +191,12 @@ function AccountRow({ account, uses }: { account: Account; uses: number }) { return (
- + actions.setAccountIcon(account.id, icon)} + /> {renaming ? ( s.config.accounts.find((a) => a.id === accountId)) const workspaces = useApp((s) => s.config.workspaces) - const isSharedSource = useApp((s) => s.config.settings.sharedSourceAccountId === accountId) const [deleteDir, setDeleteDir] = useState(false) const [error, setError] = useState(null) const [busy, setBusy] = useState(false) @@ -389,12 +391,6 @@ export function RemoveAccountDialog({ accountId }: { accountId: string }) {

{panesMsg} The account is removed from Wraithgrid.

- {isSharedSource ? ( -

- Its CLAUDE.md and skills are shared with your other accounts. Sharing stops, and each - account gets back the files it had before. -

- ) : null}
Also delete its config dir diff --git a/src/renderer/components/IconPicker.tsx b/src/renderer/components/IconPicker.tsx new file mode 100644 index 0000000..059bd2e --- /dev/null +++ b/src/renderer/components/IconPicker.tsx @@ -0,0 +1,104 @@ +// 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. +import { useRef, useState } from 'react' +import { ACCOUNT_ICON_MAX, ACCOUNT_ICONS } from '@shared/types' +import { Avatar } from './AccountIcon' + +interface Props { + icon: string + name: string + color: string + size?: number + onPick(icon: string): void +} + +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 ( + + + {at ?
: null} + {at ? ( +
e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Escape') close() + }} + > +
+ {ACCOUNT_ICONS.map((ic) => ( + + ))} +
+
{ + e.preventDefault() + if (custom.trim()) pick(custom) + }} + > + setCustom(e.target.value)} + autoFocus + /> + + +
+
+ ) : null} + + ) +} diff --git a/src/renderer/components/NewPaneDialog.tsx b/src/renderer/components/NewPaneDialog.tsx index 6bf466c..686ad42 100644 --- a/src/renderer/components/NewPaneDialog.tsx +++ b/src/renderer/components/NewPaneDialog.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { formatArgs, parseArgs } from '@shared/args' import { contractHome } from '@shared/paths' +import { AccountIcon } from './AccountIcon' import { useActions, useApp, useServices } from '../app/services' import { Dialog } from './Dialog' import { IconCheck, IconClose, IconFolder } from './icons' @@ -100,10 +101,7 @@ export function NewPaneDialog({ slotId }: { slotId: string | null }) { disabled={shell} onClick={() => setAccountId(a.id)} > - + {a.name} diff --git a/src/renderer/components/Pane.tsx b/src/renderer/components/Pane.tsx index 27f3e8d..b84fbbc 100644 --- a/src/renderer/components/Pane.tsx +++ b/src/renderer/components/Pane.tsx @@ -108,11 +108,15 @@ export const Pane = memo(function Pane({ paneId, rect, dense, zoomed, dragDisabl aria-hidden={rect ? undefined : true} onMouseDownCapture={() => actions.focusPane(paneId)} > -
+
{p.accountName ? ( - + {p.accountName} ) : null} diff --git a/src/renderer/components/SettingsView.tsx b/src/renderer/components/SettingsView.tsx index 3c556d0..9ef5f2c 100644 --- a/src/renderer/components/SettingsView.tsx +++ b/src/renderer/components/SettingsView.tsx @@ -7,8 +7,10 @@ import { TERMINAL_FONTS, TERMINAL_PALETTES, type AccentName, + type SharedMode, type ThemePreference } from '@shared/types' +import { AccountIcon } from './AccountIcon' import { useActions, useApp, useServices } from '../app/services' import { currentTheme } from '../app/store' import { PALETTE_LABEL, terminalFontStack, terminalTheme } from '../lib/term-theme' @@ -336,7 +338,7 @@ function ClaudeBinaryRow() { {contractHome(claude.path, home)} - {claude.version ? ({claude.version}) : null} + {claude.version ? {claude.version} : null} ) : ( <> @@ -380,17 +382,18 @@ function ClaudeBinaryRow() { function SharedRow() { const actions = useActions() - const accounts = useApp((s) => s.config.accounts) - const sourceId = useApp((s) => s.config.settings.sharedSourceAccountId) + const mode = useApp((s) => s.config.settings.sharedMode) const report = useApp((s) => s.sharedReport) const home = useApp((s) => s.info.homeDir) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) + const [restarted, setRestarted] = useState(null) - const choose = async (id: string | null): Promise => { - if (id === sourceId) return + const choose = async (next: SharedMode): Promise => { + if (next === mode) return setBusy(true) - setError(await actions.setSharedSource(id)) + setRestarted(null) + setError(await actions.setSharedMode(next)) setBusy(false) } @@ -411,24 +414,16 @@ function SharedRow() { }, {}) ) - const chip = (id: string | null, label: string, color?: string) => ( + const chip = (value: SharedMode, label: string) => ( ) @@ -438,23 +433,20 @@ function SharedRow() {

Shared CLAUDE.md and skills

- One CLAUDE.md and one skills folder for every account, taken from the account you pick. - Logins, settings and history stay separate. + Overall gives every account, including new ones, your normal claude setup. Logins and + history always stay separate.

- {accounts.length < 2 ? ( - Add a second account to share between them. - ) : ( -
- {chip(null, 'Off (each account its own)')} - {accounts.map((a) => chip(a.id, a.name, a.color))} -
- )} +
+ {chip('overall', 'Overall (~/.claude)')} + {chip('per-account', 'Each account its own')} +
- The other accounts get links to its CLAUDE.md and{' '} - skills/. A file they already had is kept as{' '} - …wraithgrid-backup and comes back when you turn this off. + Links CLAUDE.md, settings.json + , skills, plugins,{' '} + agents and commands from{' '} + ~/.claude. Files an account already had are kept as backups. {error ? {error} : null} {lines.length ? ( @@ -466,6 +458,19 @@ function SharedRow() { ))}
) : null} + {report ? ( + + Running claude panes read these files at start.{' '} + + {restarted !== null ? ` Restarted ${restarted}.` : null} + + ) : null}
) @@ -533,7 +538,7 @@ export function SettingsView() { height: 30 }} > - + {a.name} ))} diff --git a/src/renderer/components/ShortcutsSheet.tsx b/src/renderer/components/ShortcutsSheet.tsx index 2b68af4..65a0c46 100644 --- a/src/renderer/components/ShortcutsSheet.tsx +++ b/src/renderer/components/ShortcutsSheet.tsx @@ -8,6 +8,8 @@ const ROWS: { label: string; keys: string[] }[] = [ { label: 'Zoom pane / back to grid', keys: ['Ctrl', 'Shift', 'Z'] }, { label: 'Focus pane left / up / right / down', keys: ['Ctrl', 'Alt', '←', '↑', '→', '↓'] }, { label: 'Switch workspace', keys: ['Ctrl', 'Shift', '1', '…', '9'] }, + { label: 'Terminal text bigger / smaller / reset', keys: ['Ctrl', '+', '-', '0'] }, + { label: 'New line in claude', keys: ['Shift', 'Enter'] }, { label: 'Copy / paste in a terminal', keys: ['Ctrl', 'Shift', 'C', '/', 'V'] }, { label: 'Show this sheet', keys: ['Ctrl', 'Shift', '/'] } ] diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index 9a63fa4..d99b005 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -1,7 +1,9 @@ import type { Config } 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 { SidebarAccounts } from './SidebarAccounts' export function accountColorsFor(config: Config, wsId: string): string[] { const ws = config.workspaces.find((w) => w.id === wsId) @@ -23,7 +25,7 @@ export function Sidebar() { return (