From c1b346b1c3ba9b5e6222a18523e2452c9c637d96 Mon Sep 17 00:00:00 2001 From: shawn Date: Sat, 28 Mar 2026 00:03:02 +0800 Subject: [PATCH 1/3] refactor: move prune functionality from Settings to Worktrees view Prune is a per-repo worktree operation, so it belongs in the Worktrees panel rather than the global Settings page. Moved the prune UI to the bottom of the worktree list sidebar with a separator. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 66 ++++++++++++++++++-------------------------------- src/styles.css | 10 ++++++++ 2 files changed, 33 insertions(+), 43 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b289e80..89afd9e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -777,6 +777,29 @@ export default function App({ repoPath }: { repoPath: string }) {

{t.noWorktrees}

)} +
+
+ {t.prune} +
+ + +
+
+ {prunePreview.length > 0 && ( +
+

{t.prunePreview}

+
    + {prunePreview.map((line) => ( +
  • {line}
  • + ))} +
+
+ )} +
{!selectedWorktree ? ( @@ -849,9 +872,6 @@ export default function App({ repoPath }: { repoPath: string }) { configText={configText} onConfigChange={setConfigText} onSaveConfig={() => void handleSaveConfig()} - prunePreview={prunePreview} - onPreviewPrune={() => void handlePreviewPrune()} - onPrune={() => void handlePrune()} isBusy={isBusy} t={t} defaultTerminal={defaultTerminalId} @@ -1437,9 +1457,6 @@ function SettingsPage({ configText, onConfigChange, onSaveConfig, - prunePreview, - onPreviewPrune, - onPrune, isBusy, t, defaultTerminal, @@ -1467,9 +1484,6 @@ function SettingsPage({ configText: string; onConfigChange: (v: string) => void; onSaveConfig: () => void; - prunePreview: string[]; - onPreviewPrune: () => void; - onPrune: () => void; isBusy: boolean; t: Translations; defaultTerminal: string; @@ -1587,40 +1601,6 @@ function SettingsPage({ )} - {/* Maintenance */} - {repo && ( -
-
- {t.prune} -
- - -
-
- {repo.configErrors.length > 0 && ( -
- {repo.configErrors.map((msg) => ( -

{msg}

- ))} -
- )} - {prunePreview.length > 0 && ( -
-

{t.prunePreview}

-
    - {prunePreview.map((line) => ( -
  • {line}
  • - ))} -
-
- )} -
- )} - {/* Default Terminal */}
diff --git a/src/styles.css b/src/styles.css index 83feb99..603bca3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -506,6 +506,16 @@ pre { overflow: hidden; } +.prune-section { + margin-top: auto; + padding-top: 12px; + border-top: 1px solid var(--border-default); +} + +.prune-section .section-heading { + font-size: 12px; +} + .worktrees-detail { padding: 24px 28px; overflow-y: auto; From 556c3df64b8696597f99b7a544219f0c0a29e175 Mon Sep 17 00:00:00 2001 From: shawn Date: Sun, 29 Mar 2026 22:16:54 +0800 Subject: [PATCH 2/3] refactor: use confirmation modal for prune and clean up dead code Replace inline prune preview with a ModalShell-based confirmation flow, move +New and Prune buttons into a toolbar above the worktree list, remove help icon tooltip, unused translation keys, and orphaned CSS. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 133 +++++++++++++++++++++++-------------------- src/locales/en.ts | 11 ++-- src/locales/types.ts | 9 +-- src/locales/zh-CN.ts | 33 +++++------ src/styles.css | 71 ++++++++++------------- 5 files changed, 130 insertions(+), 127 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 89afd9e..ed7f4fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,6 +61,7 @@ import { useTheme, type ThemeMode } from "./lib/theme"; import { HooksModal, type HooksMap } from "./components/HooksModal"; import { CreateWorktreeModal, type CreateFormState } from "./components/CreateWorktreeModal"; import { DeleteExecutionModal, type DeleteExecutionState, type DeleteExecutionPhase } from "./components/DeleteExecutionModal"; +import { ModalShell } from "./components/ModalShell"; import type { ActionResponse, BootstrapResponse, @@ -162,12 +163,12 @@ export default function App({ repoPath }: { repoPath: string }) { const [configText, setConfigText] = useState(""); const [createForm, setCreateForm] = useState(createInitialForm()); const [logs, setLogs] = useState([]); - const [prunePreview, setPrunePreview] = useState([]); const [isBusy, setIsBusy] = useState(false); const [error, setError] = useState(null); const [selectedWorktreeId, setSelectedWorktreeId] = useState(null); const [deleteExecution, setDeleteExecution] = useState(null); + const [pruneModal, setPruneModal] = useState<{ candidates: string[]; loading: boolean } | null>(null); const [showCreateModal, setShowCreateModal] = useState(false); const [view, setView] = useState<"repository" | "worktrees" | "hooks" | "settings">("worktrees"); const [showActionLog, setShowActionLog] = useState(false); @@ -549,29 +550,6 @@ export default function App({ repoPath }: { repoPath: string }) { } } - async function handlePreviewPrune() { - if (!repo) return; - setIsBusy(true); - setError(null); - try { - const preview = await previewRepoPrune(repo.repoRoot); - setPrunePreview(preview); - appendLogs([ - { - level: preview.length > 0 ? "info" : "success", - message: - preview.length > 0 - ? t.logPruneCandidates(preview.length) - : t.logNoPruneCandidates, - }, - ]); - } catch (reason) { - setError(String(reason)); - } finally { - setIsBusy(false); - } - } - async function handleSetDefaultTerminal(terminalId: string) { setDefaultTerminalId(terminalId); await setDefaultTerminal(terminalId); @@ -582,10 +560,21 @@ export default function App({ repoPath }: { repoPath: string }) { await setDefaultShell(shell); } - async function handlePrune() { + async function handlePrunePreview() { if (!repo) return; + setPruneModal({ candidates: [], loading: true }); + try { + const candidates = await previewRepoPrune(repo.repoRoot); + setPruneModal({ candidates, loading: false }); + } catch { + setPruneModal(null); + } + } + + async function handlePruneConfirm() { + if (!repo) return; + setPruneModal(null); await runAction(() => pruneRepoMetadata(repo.repoRoot)); - setPrunePreview([]); } async function handleSaveCustomLauncher(input: SaveCustomLauncherInput) { @@ -637,18 +626,7 @@ export default function App({ repoPath }: { repoPath: string }) { {repo.repoRoot}
)} -
- -
+
@@ -759,6 +737,25 @@ export default function App({ repoPath }: { repoPath: string }) { ) : (
+
+ + +
{repo.worktrees.map((wt) => ( {t.noWorktrees}

)}
-
-
- {t.prune} -
- - -
-
- {prunePreview.length > 0 && ( -
-

{t.prunePreview}

-
    - {prunePreview.map((line) => ( -
  • {line}
  • - ))} -
-
- )} -
{!selectedWorktree ? ( @@ -925,6 +899,43 @@ export default function App({ repoPath }: { repoPath: string }) { /> )} + {/* Prune Confirmation Modal */} + {pruneModal && ( + setPruneModal(null)} + className="prune-confirm-modal" + > +

{t.pruneDescription}

+ {pruneModal.loading ? ( +

{t.loading}

+ ) : pruneModal.candidates.length === 0 ? ( +
+

{t.pruneNoCandidates}

+
+ ) : ( +
+

{t.pruneCandidatesFound(pruneModal.candidates.length)}

+
    + {pruneModal.candidates.map((c) => ( +
  • {c}
  • + ))} +
+
+ )} +
+ + {pruneModal.candidates.length > 0 && !pruneModal.loading && ( + + )} +
+
+ )} + {/* Create Worktree Modal */} {showCreateModal && repo && ( `${n} worktrees`, baseBranch: "base", - previewPrune: "Preview Prune", prune: "Prune", - prunePreview: "Prune preview", + pruneConfirmTitle: "Prune Worktrees", + pruneDescription: "Prune removes stale worktree metadata — entries left behind when a worktree directory was deleted manually without using git worktree remove.", + pruneNoCandidates: "No stale worktree metadata found. Everything is clean.", + pruneConfirmAction: "Confirm Prune", + pruneCandidatesFound: (n) => `Found ${n} stale worktree record(s) to clean up:`, tooling: "Tooling", logs: "Logs", clear: "Clear", @@ -113,8 +116,6 @@ export const en: Translations = { logLoaded: (path) => `Loaded ${path}`, logSavedConfig: "Saved local repo config.", logSavedHooks: "Saved hooks.", - logPruneCandidates: (n) => `Found ${n} prune candidate(s).`, - logNoPruneCandidates: "No prune candidates detected.", openInFinder: "Open in Finder", lastCommit: "Last Commit", branchLabel: "Branch", diff --git a/src/locales/types.ts b/src/locales/types.ts index e8a1426..3da667d 100644 --- a/src/locales/types.ts +++ b/src/locales/types.ts @@ -84,9 +84,12 @@ export interface Translations { allLogs: string; worktreeCount: (n: number) => string; baseBranch: string; - previewPrune: string; prune: string; - prunePreview: string; + pruneConfirmTitle: string; + pruneDescription: string; + pruneNoCandidates: string; + pruneConfirmAction: string; + pruneCandidatesFound: (n: number) => string; tooling: string; logs: string; clear: string; @@ -109,8 +112,6 @@ export interface Translations { logLoaded: (path: string) => string; logSavedConfig: string; logSavedHooks: string; - logPruneCandidates: (n: number) => string; - logNoPruneCandidates: string; openInFinder: string; lastCommit: string; branchLabel: string; diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index c1e2135..8b8231a 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -10,8 +10,8 @@ export const zhCN: Translations = { branchPlaceholder: "分支名", newBranchName: "新分支名", create: "创建", - newWorktree: "新建 Worktree", - createWorktree: "创建工作树", + newWorktree: "New", + createWorktree: "创建 Worktree", pathPreview: "目标路径", mode: "模式", modeNewBranch: "新分支", @@ -23,7 +23,7 @@ export const zhCN: Translations = { fetchRemote: "拉取远程", fetching: "拉取中...", selectBranch: "选择分支", - worktreeRootLabel: "工作树目录", + worktreeRootLabel: "Worktree 目录", setDefaultDirectory: "设置默认目录", hooks: "Hooks", addHook: "新增 Hook", @@ -57,7 +57,7 @@ export const zhCN: Translations = { cancel: "取消", force: "强制删除", delete: "删除", - noWorktrees: "未发现工作树。", + noWorktrees: "未发现 Worktree。", detached: "(游离 HEAD)", detachedShort: "(游离)", justNow: "刚刚", @@ -69,7 +69,7 @@ export const zhCN: Translations = { dirty: "✗ Dirty", clean: "✓ Clean", locked: "锁定", - prunable: "可清理", + prunable: "Prunable", enabled: "已启用", disabled: "已禁用", ready: "就绪", @@ -84,11 +84,14 @@ export const zhCN: Translations = { settings: "设置", comingSoon: "开发中", allLogs: "全部日志", - worktreeCount: (n) => `${n} 个工作树`, + worktreeCount: (n) => `${n} 个 Worktree`, baseBranch: "基础分支", - previewPrune: "预览清理", - prune: "清理", - prunePreview: "清理预览", + prune: "Prune", + pruneConfirmTitle: "Prune Worktree", + pruneDescription: "Prune 会移除过期的 Worktree 元数据记录——当 Worktree 目录被手动删除(未使用 git worktree remove)时残留的条目。", + pruneNoCandidates: "没有发现过期的 Worktree 元数据,一切正常。", + pruneConfirmAction: "确认 Prune", + pruneCandidatesFound: (n) => `发现 ${n} 条过期的 Worktree 记录:`, tooling: "工具检测", logs: "日志", clear: "清除", @@ -102,17 +105,15 @@ export const zhCN: Translations = { saveConfig: "保存配置", close: "关闭", heroTitle: "从一个本地 Git 仓库开始", - heroDescription: "选择任意仓库,扫描工作树、管理启动器、一站式运行Hooks。", + heroDescription: "选择任意仓库,扫描 Worktree、管理启动器、一站式运行 Hooks。", heroPoint1: "使用原生 git worktree porcelain 输出。", - heroPoint2: "可通过 Hooks 在创建或启动后自动预热工作树。", + heroPoint2: "可通过 Hooks 在创建或启动后自动预热 Worktree。", heroPoint3: "仓库自动化配置只保存在 Grove 本机存储里,不写入仓库。", - noWorktreeSelected: "未选择工作树", - selectWorktreeHint: "选择一个工作树,或创建新的工作树。", + noWorktreeSelected: "未选择 Worktree", + selectWorktreeHint: "选择一个 Worktree,或创建新的 Worktree。", logLoaded: (path) => `已加载 ${path}`, logSavedConfig: "已保存本机仓库配置。", logSavedHooks: "已保存 Hooks。", - logPruneCandidates: (n) => `发现 ${n} 个可清理项。`, - logNoPruneCandidates: "未检测到可清理项。", openInFinder: "在 Finder 中打开", lastCommit: "最近提交", branchLabel: "分支", @@ -160,7 +161,7 @@ export const zhCN: Translations = { cliUninstall: "卸载", cliDescription: "将 grove 命令安装到 PATH 中,方便在终端快速操作。用法:grove open .、grove run post-create、grove list", tabRepository: "仓库", - tabWorktrees: "工作树", + tabWorktrees: "Worktree", themeLabel: "外观", themeLight: "浅色", themeDark: "深色", diff --git a/src/styles.css b/src/styles.css index 603bca3..cad385f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -260,18 +260,12 @@ button { border-radius: var(--radius-full); cursor: pointer; transition: - transform 140ms ease, background 140ms ease, opacity 140ms ease; } -button:hover { - transform: translateY(-1px); -} - button:disabled { cursor: not-allowed; - transform: none; opacity: 0.55; } @@ -355,8 +349,8 @@ pre { flex-direction: column; align-items: center; gap: 3px; - padding: 8px 4px 6px; - border-radius: 8px; + padding: 8px 8px 6px; + border-radius: 10px; background: transparent; color: var(--ink-ghost); font-size: 9px; @@ -452,19 +446,6 @@ pre { -webkit-app-region: no-drag; } -.topbar-new-btn { - font-size: var(--text-sm); - font-weight: 600; - padding: 5px 12px; - background: var(--surface-strong); - color: var(--ink-secondary); - border-radius: 6px; -} - -.topbar-new-btn:hover { - background: var(--surface-stronger); - color: var(--ink); -} .main { flex: 1; @@ -506,14 +487,12 @@ pre { overflow: hidden; } -.prune-section { - margin-top: auto; - padding-top: 12px; - border-top: 1px solid var(--border-default); -} - -.prune-section .section-heading { - font-size: 12px; +.worktree-toolbar { + display: flex; + gap: 8px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-default); + margin-bottom: 4px; } .worktrees-detail { @@ -766,6 +745,28 @@ textarea { padding-left: 20px; } +.prune-preview li { + margin-top: 4px; +} + +.prune-preview code { + font-size: var(--text-xs); + word-break: break-all; +} + +.prune-confirm-modal { + width: min(480px, 90vw); + padding: 24px 26px; +} + +.prune-description { + font-size: var(--text-sm); + color: var(--ink-tertiary); + line-height: 1.5; + margin: 0; +} + + .form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -948,7 +949,6 @@ textarea { .worktree-menu-trigger:hover { background: var(--border-default); color: var(--ink-secondary); - transform: none; } .worktree-menu-popup { @@ -981,7 +981,6 @@ textarea { .worktree-menu-item:hover { background: var(--btn-ghost-bg); - transform: none; } .worktree-menu-item-danger { @@ -1565,7 +1564,6 @@ textarea { .modal-shell-close:hover { background: var(--surface-strong); color: var(--ink-secondary); - transform: none; } .alert-banner { @@ -1600,7 +1598,6 @@ textarea { .alert-dismiss:hover { background: var(--danger-bg); - transform: none; } .delete-execution-modal { @@ -1839,7 +1836,6 @@ textarea { .hook-group-remove:hover { background: var(--danger-bg); - transform: none; } .hook-group-body { @@ -1916,7 +1912,6 @@ textarea { .hook-step-remove:hover { background: var(--danger-bg); color: var(--danger); - transform: none; } .hook-step-config .field-label { @@ -1944,7 +1939,6 @@ textarea { .hook-add-step:hover { background: var(--surface-strong); - transform: none; } .hooks-empty { @@ -1993,7 +1987,6 @@ textarea { .recent-repos-toggle:hover { color: var(--teal); - transform: none; } .repo-info-line { @@ -2411,10 +2404,6 @@ textarea { color: var(--ink-tertiary); } -.theme-switcher button:hover { - transform: none; -} - .theme-switcher button.active { background: var(--surface-card); color: var(--ink); From fd1de8f2a952afb9da38238d1f6f3252888f028f Mon Sep 17 00:00:00 2001 From: shawn Date: Sun, 29 Mar 2026 22:42:41 +0800 Subject: [PATCH 3/3] feat: add repo name label to worktrees sidebar and improve topbar path visibility Display the repo name prominently above the toolbar in the worktrees panel. Make the topbar repo path more readable with stronger text color and border. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 1 + src/styles.css | 108 ++++++++++++++++++++++++++++++++----------------- 2 files changed, 71 insertions(+), 38 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index ed7f4fe..6484c30 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -737,6 +737,7 @@ export default function App({ repoPath }: { repoPath: string }) { ) : (
+
{repo.repoRoot.split("/").pop()}