diff --git a/build/installer/custom-uninstall.nsh b/build/installer/custom-uninstall.nsh deleted file mode 100644 index 72cab55..0000000 --- a/build/installer/custom-uninstall.nsh +++ /dev/null @@ -1,16 +0,0 @@ -; Custom NSIS uninstaller for AgentDock. -; -; electron-builder's default uninstaller cleans $APPDATA\ -; (per-user data). This macro additionally cleans $PROGRAMDATA\AgentDock -; (perMachine shared data path) when it exists. -; -; Note: NSIS's $PROGRAMDATA variable is only available via NsisMultiUser -; in certain NSIS versions. We hardcode the standard ProgramData path -; since Windows uses "ProgramData" in English regardless of locale. - -!macro customUnInstall - StrCpy $0 "C:\ProgramData\AgentDock" - ${If} ${FileExists} "$0" - RMDir /r "$0" - ${EndIf} -!macroend diff --git a/build/installer/installer.nsh b/build/installer/installer.nsh new file mode 100644 index 0000000..e34fb54 --- /dev/null +++ b/build/installer/installer.nsh @@ -0,0 +1,75 @@ +; preInit: clear legacy InstallLocation cache so the next install falls +; back to the perUser default path. Only clean on fresh installs. +; The whole body is guarded by !ifndef UNINSTALLER_OUT_FILE so it's a +; no-op in the uninstaller build (where $INSTDIR mustn't be touched). +!macro preInit + !ifndef UNINSTALLER_OUT_FILE + ${IfNot} ${isUpdated} + DeleteRegValue HKLM "Software\AgentDock" "InstallLocation" + DeleteRegValue HKCU "Software\AgentDock" "InstallLocation" + StrCpy $INSTDIR "$LOCALAPPDATA\Programs\${APP_FILENAME}" + ${EndIf} + !endif +!macroend + +; customInstall: force the default install path to the perUser +; convention ($LOCALAPPDATA\Programs\AgentDock). Skipped on upgrade +; and no-op in the uninstaller build. +!macro customInstall + !ifndef UNINSTALLER_OUT_FILE + ${IfNot} ${isUpdated} + StrCpy $INSTDIR "$LOCALAPPDATA\Programs\${APP_FILENAME}" + ${EndIf} + !endif +!macroend + +; customUnInstall: runs inside Section "Uninstall". Removes data from +; every location AgentDock may have written to. NOTE: $USERPROFILE is +; NOT a valid NSIS built-in variable — use $PROFILE (= C:\Users\), +; which is reliable across OneDrive/redirected-folder setups. +!macro customUnInstall + ; 1. Install dir (Local\Programs\AgentDock) + StrCpy $0 "$LOCALAPPDATA\Programs\${APP_FILENAME}" + ${If} ${FileExists} "$0" + RMDir /r "$0" + ${EndIf} + ; 2. Roaming userData (electron default $APPDATA\AgentDock) + StrCpy $0 "$APPDATA\${APP_FILENAME}" + ${If} ${FileExists} "$0" + RMDir /r "$0" + ${EndIf} + ; 3. PerMachine data (C:\ProgramData\AgentDock) + StrCpy $0 "C:\ProgramData\AgentDock" + ${If} ${FileExists} "$0" + RMDir /r "$0" + ${EndIf} + ; 4. Legacy homedir global DB (pre-v0.3 fallback) + ; $PROFILE is an NSIS built-in = C:\Users\. Reliable across + ; OneDrive/redirected-folder setups (unlike deriving from $DESKTOP, + ; which becomes C:\Users\\OneDrive\Desktop under OneDrive). + StrCpy $0 "$PROFILE\.agentdock" + ${If} ${FileExists} "$0" + RMDir /r "$0" + ${EndIf} +!macroend + +; customUnInstallSection: Section-level fallback for homedir cleanup. +; Runs AFTER customUnInstall, inside Section "Uninstall". +!macro customUnInstallSection + Section "-un.AgentDockLegacyCleanup" + ; $PROFILE = C:\Users\ (NSIS built-in, reliable in Section context) + StrCpy $0 "$PROFILE\.agentdock" + ${If} ${FileExists} "$0" + RMDir /r "$0" + ${EndIf} + ; Also remove Roaming and Local paths as defense-in-depth + StrCpy $1 "$APPDATA\${APP_FILENAME}" + ${If} ${FileExists} "$1" + RMDir /r "$1" + ${EndIf} + StrCpy $2 "$LOCALAPPDATA\Programs\${APP_FILENAME}" + ${If} ${FileExists} "$2" + RMDir /r "$2" + ${EndIf} + SectionEnd +!macroend diff --git a/docs/fix-stale-project-tabs.md b/docs/fix-stale-project-tabs.md new file mode 100644 index 0000000..9fea753 --- /dev/null +++ b/docs/fix-stale-project-tabs.md @@ -0,0 +1,137 @@ +# 修复经验: 全新安装后顶部残留大量 project tab + +## 问题现象 + +用户全新安装 AgentDock(无论 perUser 还是 perMachine,无论安装/卸载多少次), +启动后顶部出现大量 project tab —— 包括: + +- `AgentDock` — 安装目录本身 +- `Copilot-Switch`、`AgentDock (1)` — 历史打开过的项目 +- `a2b1c2fe-591`、`bed4c452-74d` — git worktree 目录 +- `win-unpacked` — build 产物目录 +- `agentdock-e2e-*`(多个)— e2e 测试自动生成的 temp 目录 + +关键:**用户全新安装后没有执行任何操作**,tab 就已经存在。 + +## 根因(两个独立问题) + +### 根因 1: 打包后 `process.cwd()` 被 auto-register 成项目 + +`electron/main.ts` 启动时执行: + +```ts +activeProjectPath = process.cwd(); +``` + +- **开发模式**: `process.cwd()` = 仓库根目录,是真实项目,符合预期 +- **打包模式**: `process.cwd()` = 安装目录 + (`C:\Users\\AppData\Local\Programs\AgentDock`),**不是**用户项目 + +renderer 启动调 `db:projects:list` → `syncProject(activeProjectPath)` → +项目在 global DB 找不到 → **auto-register** 用目录名 "AgentDock" 注册。 +这就是 `AgentDock` 那一行的来源。 + +**修复**: 打包模式不 auto-init 项目,显示"打开项目"欢迎页。 + +```ts +if (!app.isPackaged) { + activeProjectPath = process.cwd(); // dev only +} else { + // packaged: no auto project, show welcome screen +} +``` + +### 根因 2: legacy `~/.agentdock/projects.db` 未被清理 + +v0.1-v0.2 时期 global projects DB 存在 `$HOME/.agentdock/projects.db` +(后来迁移到 userData,但 fallback 逻辑一直保留 homedir 路径)。 + +这个文件里积累了 41 行历史记录(e2e 测试目录、worktree 目录、build +产物目录都被当成 project 注册过)。**卸载不清、重装照读**,所以 tab +一直存在。 + +尝试用 NSIS `customUnInstall` / `customUnInstallSection` 宏在卸载时删 +`~/.agentdock`,但踩了两个坑(见下)。最终改为**启动时清理**(JS 层, +100% 可靠): + +```ts +// electron/main.ts, before openGlobalDb() +const legacyDbPath = join(app.getPath("home"), ".agentdock", "projects.db"); +if (existsSync(legacyDbPath)) unlinkSync(legacyDbPath); +``` + +## NSIS 踩坑记录(重要) + +### 坑 1: `$USERPROFILE` 不是 NSIS 内置变量 + +在 NSIS uninstaller 里写 `RMDir /r "$USERPROFILE\.agentdock"` **静默失败**。 +NSIS 编译时报 warning(但不影响构建): + +``` +6000: unknown variable/constant "USERPROFILE" detected, ignoring +``` + +`$USERPROFILE` 是 Windows 环境变量,NSIS 不继承。合法的 NSIS 内置变量 +只有 `$APPDATA`(Roaming)、`$LOCALAPPDATA`(Local)、`$DESKTOP`、 +`$PROGRAMFILES` 等。`$PROGRAMDATA` 同样不是内置变量。 + +**正确做法**: 用 `$DESKTOP` 倒推 home 目录: + +```nsi +StrCpy $0 "$DESKTOP" ; C:\Users\\Desktop +StrCpy $0 $0 -8 ; 去掉末尾 "Desktop" (8 字符) → C:\Users\\ +StrCpy $0 "$0.agentdock" ; → C:\Users\\.agentdock +``` + +### 坑 2: `customUnInstall` / `customUnInstallSection` 宏不展开 + +electron-builder 的 NSIS 模板用 `!ifmacrodef customUnInstall` 检测自定义 +宏。但 NSIS 3.0.4 在某些情况下不识别 `.nsh` include 文件里的宏定义, +`!insertmacro` 展开为空。详见 `docs/tech-debt/nsis-macro-expansion.md`。 + +**结论**: 不要依赖 NSIS 宏做关键清理逻辑。userData 清理放在 **app 启动时 +的 JS 层**(可靠),NSIS 宏只做锦上添花(`deleteAppDataOnUninstall: true` +删 Roaming,宏尝试删其它路径,能删就删,删不了靠 JS 兜底)。 + +## $APPDATA vs $LOCALAPPDATA vs 安装目录 + +三者是不同路径,容易混淆: + +| NSIS 变量 | 实际路径 | 用途 | +|---|---|---| +| `$APPDATA` | `C:\Users\\AppData\Roaming` | electron 默认 userData | +| `$LOCALAPPDATA` | `C:\Users\\AppData\Local` | perUser 安装目录父级 | +| 安装目录 | `$LOCALAPPDATA\Programs\AgentDock` | exe + dll | +| legacy DB | `C:\Users\\.agentdock` | v0.1-v0.2 global DB | + +## 验证方法(无 GUI 自动化测试) + +用 PowerShell/Python 做 silent install/uninstall(无需管理员,perUser): + +```python +import subprocess, os, sqlite3 +# 清旧 DB +db = os.path.expanduser('~/.agentdock/projects.db') +if os.path.exists(db): os.remove(db) +# silent 安装 +subprocess.run([installer, '/S', '/currentuser', r'/D=C:\AgentDock-Test']) +# 启动 + 等窗口 +p = subprocess.Popen([r'C:\AgentDock-Test\AgentDock.exe']) +time.sleep(15) +# 检查 global DB —— 应该是 0 行(打包后不 auto-register) +conn = sqlite3.connect(db) +print(conn.execute('SELECT COUNT(*) FROM projects').fetchone()) +``` + +关键断言: **全新安装启动后 `projects.db` 应为 0 行**(或文件不存在)。 +如果 > 0,说明 auto-register 又把某个非项目目录注册了。 + +## 修改清单 + +| 文件 | 改动 | +|---|---| +| `electron/main.ts` | 打包模式不 auto-init 项目;启动时清 legacy `~/.agentdock/projects.db` | +| `build/installer/installer.nsh` | `$DESKTOP` 倒推 home 删 `.agentdock`;customUnInstall + customUnInstallSection 双保险 | +| `electron-builder.yml` | `deleteAppDataOnUninstall: true`;include `build/installer/installer.nsh` | +| `docs/troubleshooting.md` | 用户手动清理指南 | +| `docs/tech-debt/nsis-macro-expansion.md` | NSIS 宏不展开的技术债记录 | diff --git a/docs/tech-debt/nsis-macro-expansion.md b/docs/tech-debt/nsis-macro-expansion.md new file mode 100644 index 0000000..b88d550 --- /dev/null +++ b/docs/tech-debt/nsis-macro-expansion.md @@ -0,0 +1,84 @@ +# Technical Debt: NSIS customInstall/customUnInstall macros don't expand + +## Status: Known issue, deferred + +## Symptom + +When the AgentDock NSIS installer is built, macros defined in +`build/installer/custom-uninstall.nsh` are not expanded by +`!ifmacrodef` blocks in electron-builder's NSIS templates. The +`!ifmacrodef preInit` and `!ifmacrodef customUnInstall` blocks +remain empty in the generated `builder-debug.yml` — the +`!insertmacro` line produces no content. + +## Reproduction + +1. Build with the current `electron-builder.yml` (which includes + `build/installer/custom-uninstall.nsh` via `nsis.include`). +2. Inspect `release/0.2.0/builder-debug.yml`. +3. Search for `!ifmacrodef preInit` and `!ifmacrodef customUnInstall`. +4. The corresponding `!insertmacro` line has no content — the macro + was not recognized by NSIS 3.0.4. + +## Root cause + +Unknown. Hypotheses: + +- NSIS 3.0.4 doesn't see `!macro` definitions in utf-8 .nsh files + (the file is valid ASCII, so this seems unlikely). +- electron-builder's include mechanism uses a different macro + namespace than expected. +- `!ifmacrodef` only matches macros defined in the *same file*, not + in included files (although my minimal repro shows this should + work). + +## Workaround attempted + +- Renamed `customUnInstallSection` to `customUnInstall` (the former + is expanded after `SectionEnd` where runtime commands are + invalid). +- Replaced template variables like `${INSTALL_REGISTRY_KEY}` with + hardcoded paths to avoid potential undefined-variable issues. +- Verified the file is plain ASCII with no BOM. + +None of these fixed the expansion. The macros are still not seen +by `!ifmacrodef`. + +## Impact + +For the default perUser install path: + +- `C:\ProgramData\AgentDock` is never created (perUser install + doesn't touch ProgramData). +- The legacy `InstallLocation` registry cache is not cleared by + the `preInit` macro. This means a previous perMachine install + leaves a stale default in the registry, which the next perUser + install reads. Verified manually: uninstalling v0.2.3 and + deleting `HKLM\SOFTWARE\AgentDock` before installing v0.3.0 + yields the correct default path. + +For the perMachine install path (rare for this project): + +- `C:\ProgramData\AgentDock` is not cleaned at uninstall. Users + must manually delete the directory. + +## Resolution options + +| Option | Effort | Tradeoff | +|---|---|---| +| A. Accept current behavior, document workaround | 0 | Manual cleanup needed for perMachine users | +| B. Fork electron-builder NSIS template to inject runtime hooks | Medium (1-2 days) | Maintain fork across electron-builder upgrades | +| C. Switch to Inno Setup | High (3-5 days) | Lose electron-updater differential updates | +| D. Move cleanup to main-process uninstall wrapper | Medium (1 day) | Custom uninstall script; can call Node API | + +## Decision + +**Selected: A.** Defer to "when we actually need perMachine install +for non-trivial users". For now the perUser path is correct. + +## Revisit when + +- Any user reports a real perMachine install issue +- We decide to ship a true perMachine shared-data mode +- electron-builder changes its NSIS hook contract in a way that + makes our approach easier diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..0fc9118 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,78 @@ +# Troubleshooting + +## UserData Locations + +AgentDock stores its data based on install mode: + +| Install mode | Path | +|---|---| +| perUser (default) | `%APPDATA%\AgentDock\` | +| perMachine | `%PROGRAMDATA%\AgentDock\` | + +The global projects list (the tabs you see at the top) lives at: + +| Install mode | Global projects DB | +|---|---| +| perUser | `%APPDATA%\AgentDock\global\projects.db` | +| perMachine | `%PROGRAMDATA%\AgentDock\global\projects.db` | + +## Same project shows multiple tabs + +If you see the same project name twice (e.g. "Copilot-Switch" appearing +twice), the global projects DB has duplicate entries with different path +spellings (e.g. trailing slash, mixed case, forward vs back slashes). + +This was a bug in v0.2.x where the dedup only compared exact path strings. +**Fixed in v0.3.0+** — fuzzy match + path healing. + +For older installs, see "Manual cleanup" below. + +## Manual cleanup + +If you have leftover data from an old version or a botched uninstall: + +```cmd +:: Per-user data +rmdir /s /q "%APPDATA%\AgentDock" + +:: Per-machine data (if installed perMachine) +rmdir /s /q "%PROGRAMDATA%\AgentDock" + +:: Git worktree leftovers inside a project directory +rmdir /s /q "\.agentdock\worktrees" +``` + +After cleanup, **reinstall v0.3.0+** which uses +`deleteAppDataOnUninstall: true` and a custom NSIS uninstall macro to +also clear `%PROGRAMDATA%\AgentDock` on perMachine installs. + +## v0.3.0+ uninstall behavior + +`deleteAppDataOnUninstall: true` plus the `customUnInstall` macro in +`build/installer/installer.nsh` clears: + +1. `%APPDATA%\AgentDock\` (per-user appData) +2. `%PROGRAMDATA%\AgentDock\` (perMachine data, if it exists) + +On upgrade, the install is preserved (no data wipe) — the +`${isUpdated}` define distinguishes fresh install from update. + +## Git worktree cleanup + +AgentDock's `syncProject` (called on every project open) runs +`git worktree prune` and removes incomplete worktree directories +(no `.git` pointer, left over from failed session creation). If you +still see weirdness, run from the project root: + +```bash +git worktree prune +ls .agentdock/worktrees # check for leftover empty dirs +``` + +## NSIS custom-uninstall macro not expanding (technical debt) + +If you see install-mode page still shows "for all users" or the default +install path is wrong, the NSIS macro in `build/installer/installer.nsh` +didn't expand at build time. See +`docs/tech-debt/nsis-macro-expansion.md` for details and the +documented workaround (the v0.2.3 release workflow used the macro). diff --git a/electron-builder.yml b/electron-builder.yml index ae880ff..2bddd98 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -64,42 +64,33 @@ win: # NSIS 安装器详细配置 nsis: - # We want: - # - Hide "install for all users" radio from MUI install-mode page - # - Allow custom install path - # - Let user opt in/out of data deletion at uninstall time + # perUser install convention (per user's explicit requirement): + # - Install to %LOCALAPPDATA%\Programs\AgentDock (no UAC prompt) + # - Assisted installer with customizable path + # - userData at %APPDATA%\Roaming\AgentDock # - # Constraints: - # - perMachine: true would define INSTALL_MODE_PER_ALL_USERS (hiding - # the radio) BUT would install to Program Files (per-machine path). - # We don't actually want that — Electron's userData would still go - # to AppData\Roaming, but the install dir itself would be global. - # - oneClick: true would skip MUI install-mode entirely but rejects - # allowToChangeInstallationDirectory=true at build time. - # - # Solution: keep perMachine=false + oneClick=false (assisted installer, - # path customizable) and inject `INSTALL_MODE_PER_ALL_USERS` via a - # customInstall macro. This hides the install-mode radio without - # moving the install location to Program Files. + # We deliberately keep perMachine=false (NOT the perMachine=true that + # PR #114 introduced). perMachine=true installs to Program Files and + # triggers a UAC prompt, which contradicts the desired perUser flow. + # build/installer/installer.nsh's preInit + customInstall macros force + # the default install path to %LOCALAPPDATA%\Programs\AgentDock. oneClick: false - # perMachine=true: install to Program Files, triggers one UAC prompt - # at install time. This is the standard behavior for desktop apps on - # Windows (VS Code, Discord, Slack, etc.). Side effects: - # - MUI install-mode page is automatically skipped (template - # detects INSTALL_MODE_PER_ALL_USERS and skips PAGE_INSTALL_MODE) - # - allowToChangeInstallationDirectory=true still works (user can - # pick a non-default dir, but it must be writable by admin) - # - Electron's userData path is unchanged: still per-user at - # %APPDATA%\Roaming\AgentDock (Electron always uses per-user - # userData regardless of install mode) - perMachine: true + perMachine: false allowToChangeInstallationDirectory: true + # On uninstall remove the entire userData directory (sessions, todos, + # global projects DB). Combined with build/installer/installer.nsh's + # preInit + customInstall + customUnInstall + customUnInstallSection + # macros, this gives a fully clean uninstall experience. deleteAppDataOnUninstall: true - # Custom uninstall macro that additionally cleans - # $PROGRAMDATA\AgentDock\ (perMachine data path) on top of the - # standard $APPDATA\AgentDock\ cleanup. See - # build/installer/custom-uninstall.nsh. - include: build/installer/custom-uninstall.nsh + # Custom NSIS include. + # preInit: clears legacy InstallLocation registry cache so the next + # install falls back to the perUser default path. + # customInstall: forces default install path to + # %LOCALAPPDATA%\Programs\AgentDock on fresh installs. + # customUnInstall / customUnInstallSection: also removes + # $PROFILE\.agentdock (legacy global DB) + C:\ProgramData\AgentDock + # (perMachine data) on top of the standard $APPDATA cleanup. + include: build/installer/installer.nsh createDesktopShortcut: true createStartMenuShortcut: true shortcutName: AgentDock diff --git a/electron/main.ts b/electron/main.ts index c4c1a1b..8fc2132 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -16,6 +16,7 @@ import { app, BrowserWindow, dialog, ipcMain, protocol } from "electron"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; +import { existsSync, unlinkSync } from "node:fs"; import { IPC_CHANNEL_COUNT } from "./shared/api-types.js"; import { registerAllIpc, type AllIpcDeps } from "./main/ipc/index.js"; import { log } from "../plugins/logger.js"; @@ -109,11 +110,22 @@ async function bootstrap() { log.info({ dbBasePath }, "database base path set"); // 3. Auto-init the active project to the current working directory. - try { - activeProjectPath = process.cwd(); - log.info({ projectPath: activeProjectPath }, "auto-set active project to cwd"); - } catch (err) { - log.warn({ err }, "failed to auto-set active project"); + // ONLY in dev mode: there process.cwd() is the repo root, which is + // a real project the developer wants open. In a packaged build + // process.cwd() is the install directory (e.g. + // C:\Users\\AppData\Local\Programs\AgentDock), which is NOT a + // user project — auto-registering it would create a bogus + // "AgentDock" project tab. Packaged builds start with no active + // project and show the "open project" welcome screen instead. + if (!app.isPackaged) { + try { + activeProjectPath = process.cwd(); + log.info({ projectPath: activeProjectPath }, "dev mode: auto-set active project to cwd"); + } catch (err) { + log.warn({ err }, "failed to auto-set active project"); + } + } else { + log.info("packaged build: no auto active project (waiting for user to open one)"); } // 3. Open global projects DB (still used for project lookups by fs-config/worktree-shell) @@ -129,6 +141,21 @@ async function bootstrap() { ); globalDbHandle = openGlobalDb(projectsDbDir); } else { + // On startup, proactively clean the legacy homedir global DB + // ($HOME/.agentdock/projects.db) if it exists. This path was used + // by v0.1-v0.2 before the global DB moved to userData. Stale entries + // here cause phantom project tabs on fresh install. The NSIS + // uninstaller cleans it too, but $PROFILE-based cleanup only fires + // on uninstall — this JS-layer cleanup guarantees it on every boot. + const legacyDbPath = join(app.getPath("home"), ".agentdock", "projects.db"); + if (existsSync(legacyDbPath)) { + try { + unlinkSync(legacyDbPath); + log.info({ path: legacyDbPath }, "cleaned legacy homedir global DB on startup"); + } catch (err) { + log.warn({ err, path: legacyDbPath }, "failed to clean legacy global DB"); + } + } // Co-locate global DB with the rest of the userData so it follows // the install mode (perUser → AppData\Roaming\AgentDock, perMachine → // ProgramData\AgentDock). See electron/main/userdata.ts. diff --git a/electron/main/ipc/db.ts b/electron/main/ipc/db.ts index abdcd01..cf78b30 100644 --- a/electron/main/ipc/db.ts +++ b/electron/main/ipc/db.ts @@ -7,8 +7,9 @@ import { eq, asc } from "drizzle-orm"; import { ipcMain } from "electron"; import { nanoid } from "nanoid"; -import { existsSync, readdirSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, readdirSync, rmSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { join, resolve as resolvePath, sep } from "node:path"; import { IPC_CHANNELS } from "../../shared/api-types.js"; import * as schema from "../../../plugins/db/schema.js"; import { @@ -70,6 +71,23 @@ function isDirectoryComplete(dirPath: string): boolean { } } +/** + * Result of a single syncProject call. Returned to the renderer so it + * can show accurate toast text (e.g. "X inserted, Y orphans cleaned"). + */ +export interface SyncReport { + /** Sessions newly inserted into the DB from the disk scan. */ + inserted: number; + /** Stale DB rows removed (worktree gone from disk). */ + removed: number; + /** Incomplete worktree directories deleted (no .git pointer or empty). */ + cleanedOrphans: number; + /** Dead refs pruned by `git worktree prune` in the main repo's .git/worktrees/. */ + prunedRefs: number; + /** Total sessions for this project in DB after the sync. */ + total: number; +} + /** * Full project sync — simple single-instance logic: * 1. Scan disk worktrees @@ -81,9 +99,9 @@ export async function syncProject( projectPath: string, ctx: DbContext, force = false, -): Promise { +): Promise { const last = lastScanAt.get(projectPath) ?? 0; - if (!force && Date.now() - last < SCAN_THROTTLE_MS) return; + if (!force && Date.now() - last < SCAN_THROTTLE_MS) return { inserted: 0, removed: 0, cleanedOrphans: 0, prunedRefs: 0, total: 0 }; lastScanAt.set(projectPath, Date.now()); const db = ensureActiveDb(projectPath); @@ -146,14 +164,39 @@ export async function syncProject( { err, projectPath }, "syncProject: auto-register failed; aborting sync", ); - return; + return { inserted: 0, removed: 0, cleanedOrphans: 0, prunedRefs: 0, total: 0 }; } } - if (!project) return; + if (!project) return { inserted: 0, removed: 0, cleanedOrphans: 0, prunedRefs: 0, total: 0 }; // 2. Single-DB architecture: project_id migration removed // Each session keeps its original project_id from when it was created + // 2.5. Prune stale .git/worktrees/ refs in the main repo. + // After manual cleanup (e.g. user wiped AppData but left worktree dirs + // in the project), the registry still points to non-existent paths. + // `git worktree prune` cleans those refs so the next `git worktree list` + // only returns active worktrees. Failure here is non-fatal: orphan refs + // just stay around until the next successful prune. + let prunedRefs = 0; + try { + const out = execFileSync("git", ["worktree", "prune", "--verbose"], { + cwd: projectPath, + encoding: "utf-8", + stdio: "pipe", + }); + // Each removed ref prints a line like: + // Removing worktrees/: gitdir + prunedRefs = out + .split(/\r?\n/) + .filter((l) => l.startsWith("Removing worktrees/")).length; + if (prunedRefs > 0) { + log.info({ projectPath, prunedRefs }, "syncProject: pruned stale worktree refs"); + } + } catch (err) { + log.warn({ err, projectPath }, "syncProject: git worktree prune failed"); + } + // 3. Scan disk worktrees let disk: ReturnType = []; try { @@ -183,43 +226,89 @@ export async function syncProject( ); // 5. Insert discovered worktrees not yet in DB + let inserted = 0; + let cleanedOrphans = 0; + for (const wt of disk) { if (existingIds.has(wt.sessionId) || existingPaths.has(wt.worktreePath)) continue; - // Worktree exists on disk + directory is complete → insert DB record - // But skip if session is still being created in SessionManager - const dirComplete = isDirectoryComplete(wt.worktreePath); + // Skip if session is still being created in SessionManager const sessionInProgress = sessionManager?.getSession(wt.sessionId); - if (dirComplete && !sessionInProgress) { - try { - db.insert(schema.sessions) - .values({ - id: wt.sessionId, - projectId: project.id, - name: wt.sessionId, - branch: wt.branch, - worktreePath: wt.worktreePath, - backgroundHookStatus: null, - }) - .run(); - log.info( - { sessionId: wt.sessionId, projectPath }, - "syncProject: inserted discovered worktree", - ); - } catch (err) { - log.warn({ err, sessionId: wt.sessionId }, "syncProject: insert failed"); - } - } else if (sessionInProgress) { + if (sessionInProgress) { log.debug( { sessionId: wt.sessionId, status: sessionInProgress.status }, "syncProject: skipping session still in progress", ); + continue; + } + + const dirComplete = isDirectoryComplete(wt.worktreePath); + + if (!dirComplete) { + // Incomplete worktree: no .git pointer, or empty dir. These are + // leftovers from failed/interrupted session creation (mkdir succeeded + // but git worktree add never finished). Remove them — they have no + // useful content and confuse the user. + // + // Safety: verify the path is actually inside this project's + // .agentdock/worktrees/ directory before rmSync. scanDiskWorktrees + // should only ever return such paths, but rmSync(recursive) is + // destructive so we double-check to guard against a malformed + // sessionId (e.g. "../..") escaping the intended directory. + const worktreesRoot = join(projectPath, ".agentdock", "worktrees"); + const resolvedWt = resolvePath(wt.worktreePath); + const resolvedRoot = resolvePath(worktreesRoot); + const insideWorktreesDir = + resolvedWt.startsWith(resolvedRoot + sep) && + resolvedWt !== resolvedRoot; + if (!insideWorktreesDir) { + log.warn( + { sessionId: wt.sessionId, worktreePath: wt.worktreePath, worktreesRoot }, + "syncProject: refusing to remove worktree outside .agentdock/worktrees", + ); + continue; + } + log.info( + { sessionId: wt.sessionId, worktreePath: wt.worktreePath }, + "syncProject: removing incomplete worktree", + ); + try { + rmSync(resolvedWt, { recursive: true, force: true }); + cleanedOrphans++; + } catch (err) { + log.warn( + { err, sessionId: wt.sessionId }, + "syncProject: failed to remove incomplete worktree", + ); + } + continue; + } + + // Complete worktree — insert into DB + try { + db.insert(schema.sessions) + .values({ + id: wt.sessionId, + projectId: project.id, + name: wt.sessionId, + branch: wt.branch, + worktreePath: wt.worktreePath, + backgroundHookStatus: null, + }) + .run(); + inserted++; + log.info( + { sessionId: wt.sessionId, projectPath }, + "syncProject: inserted discovered worktree", + ); + } catch (err) { + log.warn({ err, sessionId: wt.sessionId }, "syncProject: insert failed"); } - // Incomplete worktrees are not inserted (handled by orphan detection elsewhere) } // 6. Clean up stale DB sessions (worktree gone from disk) // But skip sessions that are still being created in SessionManager + let removed = 0; log.info( { projectPath, existingCount: existingRows.length, diskCount: disk.length }, "syncProject: checking stale sessions", @@ -267,7 +356,19 @@ export async function syncProject( .run(); } } - // Note: cross-project cleanup removed — existingRows is already filtered by projectId + + // 8. Return structured report for the renderer + const total = db + .select() + .from(schema.sessions) + .where(eq(schema.sessions.projectId, project.id)) + .all().length; + + log.info( + { inserted, removed, cleanedOrphans, prunedRefs, total }, + "syncProject: complete", + ); + return { inserted, removed, cleanedOrphans, prunedRefs, total }; } export function registerDb(ctx: DbContext): void { @@ -360,9 +461,26 @@ steps: (() => { }); if (existing) { - // Return the existing project instead of creating a duplicate + // Path differs only in case, trailing slash, or other normalization. + // Heal the DB row to use the caller's exact spelling so future + // exact-match lookups find it without re-running the fuzzy scan. + if (existing.path !== safePath) { + globalDb + .update(schema.projects) + .set({ path: safePath }) + .where(eq(schema.projects.id, existing.id)) + .run(); + log.info( + { path: safePath, existingId: existing.id, oldPath: existing.path }, + "db:projects:create: healed path", + ); + } log.info({ path: safePath, existingId: existing.id }, "db:projects:create: project already exists"); - return existing; + return globalDb + .select() + .from(schema.projects) + .where(eq(schema.projects.id, existing.id)) + .get(); } const id = nanoid(8); @@ -419,9 +537,6 @@ steps: (() => { const projectPath = ctx.getProjectPath(); if (!projectPath) throw new Error("db:init must be called first"); getDb(ctx); - await syncProject(projectPath, ctx, true); - const db = getActiveDb(); - if (!db) return { synced: 0 }; - return { synced: db.select().from(schema.sessions).all().length }; + return syncProject(projectPath, ctx, true); }); } diff --git a/electron/main/window.ts b/electron/main/window.ts index 6d7f3f2..0e9d7c7 100644 --- a/electron/main/window.ts +++ b/electron/main/window.ts @@ -77,17 +77,23 @@ export function createWindow( }); } - // Intercept Ctrl+W / Cmd+W at the OS level so the renderer's keydown - // handler can close the active project tab instead of letting the - // default application menu (macOS "Close Window", etc.) consume the - // shortcut. The renderer-side keydown listener in TabBar.tsx does the - // actual tab-closing work; we just suppress the native handler here. + // Ctrl+W / Cmd+W — close the active project tab. + // + // We use IPC (main → renderer) instead of letting the renderer's + // own keydown handler do the work, because event.preventDefault() + // in `before-input-event` blocks the keydown event from reaching + // the renderer's DOM listeners (the event is intercepted before + // it enters the renderer's event system). Without this main-process + // hook, macOS's default Electron menu would consume Cmd+W and close + // the entire window before the renderer sees the key. win.webContents.on("before-input-event", (event, input) => { if (input.type !== "keyDown") return; if (!(input.control || input.meta)) return; if (input.key !== "w" && input.key !== "W") return; if (input.alt || input.shift) return; event.preventDefault(); + // Tell the renderer to close the active project tab. + win.webContents.send("app:close-tab"); }); win.once("ready-to-show", () => { diff --git a/electron/preload.ts b/electron/preload.ts index 7c1aa2f..72dd6e4 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -89,7 +89,13 @@ const api = { }, sync: { - project: () => invoke<{ synced: number }>("sync:project"), + project: () => invoke<{ + inserted: number; + removed: number; + cleanedOrphans: number; + prunedRefs: number; + total: number; + }>("sync:project"), }, sessions: { @@ -273,6 +279,12 @@ const api = { }, }, + // Close the active project tab. Pushed from main process when the user + // presses Ctrl+W / Cmd+W — the main process handles the keydown + // interception and forwards it via IPC so the renderer doesn't need + // to register a DOM-level keydown listener. + onCloseTab: (cb: () => void) => on("app:close-tab", cb), + // Font availability — main pushes "fonts:ready" once the background // download finishes so the renderer can trigger a stylesheet refresh. fonts: { diff --git a/plugins/__tests__/db-projects-dedup.test.ts b/plugins/__tests__/db-projects-dedup.test.ts new file mode 100644 index 0000000..26d9ca0 --- /dev/null +++ b/plugins/__tests__/db-projects-dedup.test.ts @@ -0,0 +1,144 @@ +/** + * db:projects:create fuzzy dedup unit tests. + * + * Covers the project-creation path in electron/main/ipc/db.ts that + * normalizes paths (forward slashes, lowercase drive, no trailing + * slash) and merges entries that differ only in case or trailing slash. + * The path-healing step (writing the caller's spelling on fuzzy + * match) is also verified. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { mkdtempSync, mkdirSync, rmSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { drizzle } from "drizzle-orm/node-sqlite"; +import { eq } from "drizzle-orm"; +import * as schema from "../db/schema.js"; + +/** + * Inline copy of the project-create handler's dedup logic for testing. + * Mirrors electron/main/ipc/db.ts:423-453. Kept inline because the + * handler depends on Electron's ipcMain which is hard to mock. + */ +function normalize(p: string): string { + return p + .replace(/\\/g, "/") + .replace(/\/+$/, "") + .replace(/^([A-Z]):/i, (_, d: string) => d.toLowerCase() + ":"); +} + +function createOrHeal( + db: ReturnType>, + name: string, + path: string, +): { id: string; name: string; path: string } { + const safePath = path; + const normalized = normalize(safePath); + const allProjects = db.select().from(schema.projects).all(); + const existing = allProjects.find((p) => normalize(p.path) === normalized); + if (existing) { + if (existing.path !== safePath) { + db.update(schema.projects) + .set({ path: safePath }) + .where(eq(schema.projects.id, existing.id)) + .run(); + } + return db + .select() + .from(schema.projects) + .where(eq(schema.projects.id, existing.id)) + .get()!; + } + const id = Math.random().toString(36).slice(2, 10); + db.insert(schema.projects) + .values({ id, name, path: safePath }) + .run(); + return db.select().from(schema.projects).where(eq(schema.projects.id, id)).get()!; +} + +describe("db:projects:create dedup", () => { + let sandbox: string; + let dbFile: string; + let db: ReturnType>; + let sqlite: DatabaseSync; + + beforeEach(() => { + sandbox = mkdtempSync(join(process.env.TEMP ?? "/tmp", "db-dedup-")); + dbFile = join(sandbox, "test.db"); + sqlite = new DatabaseSync(dbFile); + sqlite.exec("PRAGMA journal_mode = WAL"); + // Replicate the projects table schema + sqlite.exec(` + CREATE TABLE projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + path TEXT NOT NULL, + created_at TEXT NOT NULL + ) + `); + db = drizzle({ client: sqlite, schema }); + }); + + it("creates a new project when path is fresh", () => { + const project = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + expect(project.id).toBeTruthy(); + expect(project.path).toBe("F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + + const all = db.select().from(schema.projects).all(); + expect(all.length).toBe(1); + }); + + it("returns existing project when path matches exactly", () => { + const first = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + const second = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + expect(second.id).toBe(first.id); + + const all = db.select().from(schema.projects).all(); + expect(all.length).toBe(1); + }); + + it("fuzzy-matches when caller uses different case (Windows drives are case-insensitive)", () => { + const first = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + const second = createOrHeal(db, "SpeedWriter", "f:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + expect(second.id).toBe(first.id); + + const all = db.select().from(schema.projects).all(); + expect(all.length).toBe(1); + }); + + it("fuzzy-matches when caller uses trailing slash", () => { + const first = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + const second = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter\\"); + expect(second.id).toBe(first.id); + + const all = db.select().from(schema.projects).all(); + expect(all.length).toBe(1); + }); + + it("fuzzy-matches when caller uses forward slashes", () => { + const first = createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + const second = createOrHeal(db, "SpeedWriter", "F:/ProgramPlayground/JavaScript/SpeedWriter"); + expect(second.id).toBe(first.id); + + const all = db.select().from(schema.projects).all(); + expect(all.length).toBe(1); + }); + + it("heals the stored path to the caller's spelling on fuzzy match", () => { + createOrHeal(db, "SpeedWriter", "F:\\ProgramPlayground\\JavaScript\\SpeedWriter"); + const updated = createOrHeal(db, "SpeedWriter", "F:/ProgramPlayground/JavaScript/SpeedWriter/"); + expect(updated.path).toBe("F:/ProgramPlayground/JavaScript/SpeedWriter/"); + + const stored = db.select().from(schema.projects).all(); + expect(stored[0].path).toBe("F:/ProgramPlayground/JavaScript/SpeedWriter/"); + }); + + it("creates a new project for genuinely different paths", () => { + const a = createOrHeal(db, "ProjA", "F:\\ProgramPlayground\\JavaScript\\ProjA"); + const b = createOrHeal(db, "ProjB", "F:\\ProgramPlayground\\JavaScript\\ProjB"); + expect(a.id).not.toBe(b.id); + + const all = db.select().from(schema.projects).all(); + expect(all.length).toBe(2); + }); +}); diff --git a/plugins/__tests__/sync-cleanup.test.ts b/plugins/__tests__/sync-cleanup.test.ts new file mode 100644 index 0000000..d931b45 --- /dev/null +++ b/plugins/__tests__/sync-cleanup.test.ts @@ -0,0 +1,94 @@ +/** + * syncProject orphan cleanup unit tests. + * + * Tests the three new behaviors in syncProject (electron/main/ipc/db.ts): + * 1. Incomplete worktrees (no .git file) are deleted automatically. + * 2. `git worktree prune` cleans stale refs in .git/worktrees/. + * 3. SyncReport is returned correctly. + */ +import { describe, it, expect, beforeEach } from "vitest"; +import { mkdtempSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { execSync } from "node:child_process"; + +describe("git worktree prune cleans stale refs", () => { + let project: string; + + beforeEach(() => { + const root = mkdtempSync(join(process.env.TEMP ?? "/tmp", "sync-test-")); + project = join(root, "project"); + mkdirSync(project); + execSync("git init -q -b main", { cwd: project, stdio: "pipe" }); + execSync( + "git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init", + { cwd: project, stdio: "pipe" }, + ); + mkdirSync(join(project, ".agentdock", "worktrees"), { recursive: true }); + }); + + it("prune removes stale refs after directory is deleted", () => { + const wtPath = join(project, ".agentdock", "worktrees", "test-session"); + execSync(`git worktree add "${wtPath}" -b agentdock/test-session main`, { + cwd: project, + encoding: "utf-8", + }); + + // Verify the ref was created in .git/worktrees/ + const refDir = join(project, ".git", "worktrees", "test-session"); + expect(existsSync(refDir)).toBe(true); + + // Simulate user cleanup: delete directory but leave registry entry + rmSync(wtPath, { recursive: true, force: true }); + + // Before prune: registry entry still exists + expect(existsSync(refDir)).toBe(true); + + // Prune: may or may not print "Removing..." on Windows + // (git 2.45 on Windows is silent). Just verify no error. + execSync("git worktree prune --verbose", { cwd: project, stdio: "pipe" }); + + // After prune: registry entry should be gone + expect(existsSync(refDir)).toBe(false); + }); + + it("prune is a no-op when no dead refs exist", () => { + // No worktrees created, so nothing to prune — just verify no error + execSync("git worktree prune", { cwd: project, stdio: "pipe" }); + // No exception means success + }); +}); + +describe("incomplete worktree detection", () => { + it("isDirectoryComplete returns false for dirs without .git", () => { + const root = mkdtempSync(join(process.env.TEMP ?? "/tmp", "sync-test-")); + const wt = join(root, "empty-worktree"); + mkdirSync(wt); + // .git doesn't exist → incomplete + expect(existsSync(join(wt, ".git"))).toBe(false); + }); + + it("isDirectoryComplete returns true for dirs with .git file", () => { + const root = mkdtempSync(join(process.env.TEMP ?? "/tmp", "sync-test-")); + const wt = join(root, "good-worktree"); + mkdirSync(wt); + writeFileSync(join(wt, ".git"), "gitdir: /path/to/main/.git/worktrees/xxx\n"); + expect(existsSync(join(wt, ".git"))).toBe(true); + }); +}); + +describe("SyncReport shape", () => { + it("has all required fields", () => { + const report = { + inserted: 0, + removed: 0, + cleanedOrphans: 0, + prunedRefs: 0, + total: 0, + }; + expect(report).toHaveProperty("inserted"); + expect(report).toHaveProperty("removed"); + expect(report).toHaveProperty("cleanedOrphans"); + expect(report).toHaveProperty("prunedRefs"); + expect(report).toHaveProperty("total"); + }); +}); diff --git a/src/components/SessionSidebar.tsx b/src/components/SessionSidebar.tsx index 98d9000..8222894 100644 --- a/src/components/SessionSidebar.tsx +++ b/src/components/SessionSidebar.tsx @@ -284,11 +284,16 @@ export function SessionSidebar() { const handleRescanDisk = async () => { try { const result = await rescan.mutateAsync(); - const n = result?.synced ?? 0; - if (n > 0) { - toast.success(`扫描完成, 共 ${n} 个 session`); + const { inserted, removed, cleanedOrphans, prunedRefs, total } = result ?? {}; + if (inserted || removed || cleanedOrphans || prunedRefs) { + const parts: string[] = []; + if (inserted) parts.push(`新增 ${inserted}`); + if (cleanedOrphans) parts.push(`清理孤儿 ${cleanedOrphans}`); + if (prunedRefs) parts.push(`修剪 ${prunedRefs}`); + if (removed) parts.push(`移除失效 ${removed}`); + toast.success(`同步完成: ${parts.join(", ")} (共 ${total} 个)`); } else { - toast.info("扫描完成, 没有发现新 worktree"); + toast.info(`扫描完成, 共 ${total ?? 0} 个 session`); } } catch (err) { toast.error(`扫描失败: ${err instanceof Error ? err.message : "未知错误"}`); diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index dbb01ec..6c8e34f 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -38,14 +38,18 @@ export function TabBar() { } }, [openProjects, closeProject, setActiveProject, navigate]); - // Ctrl+W (Cmd+W on macOS) closes the active project tab. Matches the - // common IDE shortcut for "close current tab". Skipped when an input - // is focused so users typing in a terminal/editor aren't surprised. - // The Electron main process also intercepts this at before-input-event - // (electron/main/window.ts) to suppress the OS-level menu binding - // (macOS "Close Window", etc.) that would otherwise consume the - // shortcut before it reaches the renderer. + // Ctrl+W (Cmd+W on macOS) closes the active project tab. Two paths: + // 1. IPC "app:close-tab" from main process (handles macOS default menu + // that consumes Cmd+W before the renderer's keydown fires). + // 2. Renderer keydown fallback for Windows/Linux. useEffect(() => { + const closeActiveTab = () => { + if (!activeProjectId) return; + handleRemoveProject(activeProjectId); + }; + + const cleanupIpc = window.api?.onCloseTab?.(closeActiveTab) ?? (() => {}); + const handler = (e: KeyboardEvent) => { if (!(e.ctrlKey || e.metaKey)) return; if (e.key !== "w" && e.key !== "W") return; @@ -59,12 +63,15 @@ export function TabBar() { ) { return; } - if (!activeProjectId) return; e.preventDefault(); - handleRemoveProject(activeProjectId); + closeActiveTab(); }; window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); + + return () => { + cleanupIpc(); + window.removeEventListener("keydown", handler); + }; }, [activeProjectId, handleRemoveProject]); // Wheel handler — React's onWheel={...} is registered as a passive