diff --git a/src/App.tsx b/src/App.tsx index b289e80..6484c30 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,26 @@ export default function App({ repoPath }: { repoPath: string }) { ) : (
+
{repo.repoRoot.split("/").pop()}
+
+ + +
{repo.worktrees.map((wt) => ( void handleSaveConfig()} - prunePreview={prunePreview} - onPreviewPrune={() => void handlePreviewPrune()} - onPrune={() => void handlePrune()} isBusy={isBusy} t={t} defaultTerminal={defaultTerminalId} @@ -905,6 +900,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 && ( void; onSaveConfig: () => void; - prunePreview: string[]; - onPreviewPrune: () => void; - onPrune: () => void; isBusy: boolean; t: Translations; defaultTerminal: string; @@ -1587,40 +1613,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/locales/en.ts b/src/locales/en.ts index 6e0a3f8..7143207 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -10,7 +10,7 @@ export const en: Translations = { branchPlaceholder: "branch name", newBranchName: "New branch name", create: "Create", - newWorktree: "New Worktree", + newWorktree: "New", createWorktree: "Create Worktree", pathPreview: "Target Path", mode: "Mode", @@ -86,9 +86,12 @@ export const en: Translations = { allLogs: "All Logs", worktreeCount: (n) => `${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 83feb99..4e0bfab 100644 --- a/src/styles.css +++ b/src/styles.css @@ -27,7 +27,9 @@ --topbar-inset-left: 16px; /* ── Font stacks ── */ - --font-mono: "SF Mono", "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, "Liberation Mono", monospace; + --font-mono: + "SF Mono", "Cascadia Code", "Fira Code", "JetBrains Mono", Consolas, "Liberation Mono", + monospace; /* ── Ink (text hierarchy) ── */ --ink: #10212e; @@ -39,12 +41,12 @@ /* ── Teal accent ── */ --teal: #2e6b63; - --teal-bg: rgba(46, 107, 99, 0.10); + --teal-bg: rgba(46, 107, 99, 0.1); --teal-bg-strong: rgba(46, 107, 99, 0.16); --teal-border: rgba(46, 107, 99, 0.25); - --teal-focus: rgba(46, 107, 99, 0.70); + --teal-focus: rgba(46, 107, 99, 0.7); --teal-muted: rgba(46, 107, 99, 0.75); - --teal-hover: rgba(46, 107, 99, 0.90); + --teal-hover: rgba(46, 107, 99, 0.9); /* ── Surfaces ── */ --surface-card: rgba(255, 255, 255, 0.74); @@ -53,7 +55,7 @@ --surface-modal: rgba(254, 251, 246, 0.98); --surface-input: rgba(250, 252, 255, 0.88); --surface-warm: rgba(255, 248, 238, 0.62); - --surface-raised: rgba(255, 255, 255, 0.70); + --surface-raised: rgba(255, 255, 255, 0.7); --surface-hover: rgba(16, 33, 46, 0.05); --surface-subtle: rgba(16, 33, 46, 0.03); --surface-muted: rgba(16, 33, 46, 0.06); @@ -62,7 +64,7 @@ /* ── Borders ── */ --border-faint: rgba(16, 33, 46, 0.06); - --border-default: rgba(16, 33, 46, 0.10); + --border-default: rgba(16, 33, 46, 0.1); --border-strong: rgba(16, 33, 46, 0.12); /* ── Shadows ── */ @@ -92,7 +94,7 @@ --danger: #8e2319; --danger-bg: rgba(197, 61, 46, 0.12); --danger-bg-subtle: rgba(197, 61, 46, 0.06); - --danger-border: rgba(197, 61, 46, 0.20); + --danger-border: rgba(197, 61, 46, 0.2); /* ── Warning ── */ --warning: #8a5400; @@ -106,7 +108,7 @@ --purple: #5842ba; --purple-bg: rgba(88, 66, 186, 0.12); --purple-bg-hover: rgba(88, 66, 186, 0.22); - --purple-muted: rgba(88, 66, 186, 0.70); + --purple-muted: rgba(88, 66, 186, 0.7); /* ── Diff ── */ --diff-add-bg: rgba(26, 127, 55, 0.1); @@ -122,7 +124,7 @@ --file-renamed: #6639ba; /* ── Toggle ── */ - --toggle-off: rgba(16, 33, 46, 0.20); + --toggle-off: rgba(16, 33, 46, 0.2); --toggle-on: #34a853; --toggle-thumb: #ffffff; @@ -146,21 +148,21 @@ --ink: #e8e0d8; --ink-strong: rgba(232, 224, 216, 0.92); --ink-secondary: rgba(232, 224, 216, 0.72); - --ink-tertiary: rgba(232, 224, 216, 0.50); + --ink-tertiary: rgba(232, 224, 216, 0.5); --ink-ghost: rgba(232, 224, 216, 0.38); --ink-faint: rgba(232, 224, 216, 0.22); --teal: #4eada2; --teal-bg: rgba(78, 173, 162, 0.14); --teal-bg-strong: rgba(78, 173, 162, 0.22); - --teal-border: rgba(78, 173, 162, 0.30); + --teal-border: rgba(78, 173, 162, 0.3); --teal-focus: rgba(78, 173, 162, 0.65); - --teal-muted: rgba(78, 173, 162, 0.70); + --teal-muted: rgba(78, 173, 162, 0.7); --teal-hover: rgba(78, 173, 162, 0.85); --surface-card: rgba(255, 255, 255, 0.06); --surface-topbar: rgba(28, 24, 20, 0.85); - --surface-sidebar: rgba(28, 24, 20, 0.50); + --surface-sidebar: rgba(28, 24, 20, 0.5); --surface-modal: rgba(32, 28, 24, 0.98); --surface-input: rgba(255, 255, 255, 0.06); --surface-warm: rgba(255, 248, 238, 0.04); @@ -172,12 +174,12 @@ --surface-stronger: rgba(255, 255, 255, 0.12); --border-faint: rgba(255, 255, 255, 0.06); - --border-default: rgba(255, 255, 255, 0.10); + --border-default: rgba(255, 255, 255, 0.1); --border-strong: rgba(255, 255, 255, 0.14); --shadow-card: 0 24px 60px rgba(0, 0, 0, 0.25); - --shadow-modal: 0 32px 90px rgba(0, 0, 0, 0.50); - --shadow-menu: 0 8px 24px rgba(0, 0, 0, 0.40); + --shadow-modal: 0 32px 90px rgba(0, 0, 0, 0.5); + --shadow-menu: 0 8px 24px rgba(0, 0, 0, 0.4); --shadow-panel: -8px 0 40px rgba(0, 0, 0, 0.35); --backdrop: rgba(0, 0, 0, 0.55); @@ -202,7 +204,7 @@ --warning: #f0c050; --warning-bg: rgba(240, 192, 80, 0.14); --warning-border: rgba(240, 192, 80, 0.22); - --warning-badge-bg: rgba(240, 192, 80, 0.20); + --warning-badge-bg: rgba(240, 192, 80, 0.2); --amber-bg: rgba(240, 192, 80, 0.14); --amber-text: #f0c050; @@ -260,18 +262,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 +351,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; @@ -364,7 +360,9 @@ pre { white-space: nowrap; border: none; cursor: pointer; - transition: color 140ms ease, background 140ms ease; + transition: + color 140ms ease, + background 140ms ease; position: relative; } @@ -429,10 +427,10 @@ pre { padding: 3px 10px; border-radius: 999px; background: var(--surface-subtle); - border: 1px solid var(--border-faint); + border: 1px solid var(--border-default); font-family: var(--font-mono); font-size: 11px; - color: var(--ink-tertiary); + color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -452,20 +450,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; min-width: 0; @@ -506,6 +490,21 @@ pre { overflow: hidden; } +.worktree-toolbar { + display: flex; + gap: 8px; + padding-bottom: 10px; +} + +.repo-name-label { + padding: 4px 4px 10px; + font-weight: 700; + font-size: 18px; + color: var(--ink); + border-bottom: 1px solid var(--border-default); + margin-bottom: 10px; +} + .worktrees-detail { padding: 24px 28px; overflow-y: auto; @@ -756,6 +755,27 @@ 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)); } @@ -797,7 +817,9 @@ textarea { border-radius: var(--radius-sm); cursor: pointer; border: 1px solid transparent; - transition: background 100ms ease, border-color 100ms ease; + transition: + background 100ms ease, + border-color 100ms ease; display: flex; align-items: center; gap: 8px; @@ -938,7 +960,6 @@ textarea { .worktree-menu-trigger:hover { background: var(--border-default); color: var(--ink-secondary); - transform: none; } .worktree-menu-popup { @@ -971,7 +992,6 @@ textarea { .worktree-menu-item:hover { background: var(--btn-ghost-bg); - transform: none; } .worktree-menu-item-danger { @@ -1162,7 +1182,6 @@ textarea { filter: brightness(0) invert(1); } - .iterm2-glyph { display: inline-flex; align-items: center; @@ -1555,7 +1574,6 @@ textarea { .modal-shell-close:hover { background: var(--surface-strong); color: var(--ink-secondary); - transform: none; } .alert-banner { @@ -1590,7 +1608,6 @@ textarea { .alert-dismiss:hover { background: var(--danger-bg); - transform: none; } .delete-execution-modal { @@ -1689,8 +1706,6 @@ textarea { margin-top: 18px; } - - /* Create Worktree modal (right slide-out) */ .create-modal-panel { position: fixed; @@ -1829,7 +1844,6 @@ textarea { .hook-group-remove:hover { background: var(--danger-bg); - transform: none; } .hook-group-body { @@ -1906,7 +1920,6 @@ textarea { .hook-step-remove:hover { background: var(--danger-bg); color: var(--danger); - transform: none; } .hook-step-config .field-label { @@ -1934,7 +1947,6 @@ textarea { .hook-add-step:hover { background: var(--surface-strong); - transform: none; } .hooks-empty { @@ -1983,7 +1995,6 @@ textarea { .recent-repos-toggle:hover { color: var(--teal); - transform: none; } .repo-info-line { @@ -2047,11 +2058,21 @@ textarea { text-align: center; } -.file-status-modified { color: var(--file-modified); } -.file-status-added { color: var(--file-added); } -.file-status-deleted { color: var(--file-deleted); } -.file-status-renamed { color: var(--file-renamed); } -.file-status-untracked { color: var(--ink-tertiary); } +.file-status-modified { + color: var(--file-modified); +} +.file-status-added { + color: var(--file-added); +} +.file-status-deleted { + color: var(--file-deleted); +} +.file-status-renamed { + color: var(--file-renamed); +} +.file-status-untracked { + color: var(--ink-tertiary); +} .file-path { flex: 1; @@ -2186,7 +2207,9 @@ textarea { } @keyframes mini-spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } /* ─── Custom Launcher ─── */ @@ -2357,7 +2380,9 @@ textarea { font-weight: 400; cursor: pointer; z-index: 9999; - animation: toast-in 200ms ease, toast-out 300ms ease 2.7s forwards; + animation: + toast-in 200ms ease, + toast-out 300ms ease 2.7s forwards; } .toast-success { @@ -2373,13 +2398,23 @@ textarea { } @keyframes toast-in { - from { opacity: 0; transform: translateY(12px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } } @keyframes toast-out { - from { opacity: 1; } - to { opacity: 0; } + from { + opacity: 1; + } + to { + opacity: 0; + } } /* ─── Theme Switcher ─── */ @@ -2401,10 +2436,6 @@ textarea { color: var(--ink-tertiary); } -.theme-switcher button:hover { - transform: none; -} - .theme-switcher button.active { background: var(--surface-card); color: var(--ink);