@fatdoge/wtree 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>TreeLab - Git Worktree Manager</title>
8
- <script type="module" crossorigin src="/assets/index-C7vu17w3.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-DsCX4t5o.css">
8
+ <script type="module" crossorigin src="/assets/index-sP_n0D3A.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-bLnnvz_q.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
@@ -162,27 +162,39 @@ export function createWorktreeRouter(getRepoRoot) {
162
162
  router.get('/worktrees/:id/staged', (req, res) => {
163
163
  try {
164
164
  const wtPath = pathFromId(req.params.id);
165
- const statusResult = git(wtPath, ['diff', '--cached', '--name-status']);
166
- const files = statusResult.ok
167
- ? statusResult.stdout
168
- .split('\n')
169
- .map((line) => line.trim())
170
- .filter(Boolean)
171
- .map((line) => {
172
- const tab = line.indexOf('\t');
173
- if (tab === -1)
174
- return { status: '?', path: line };
175
- return { status: line.slice(0, tab).trim(), path: line.slice(tab + 1).trim() };
176
- })
177
- : [];
178
- const diffResult = git(wtPath, ['diff', '--cached']);
179
- const diff = diffResult.ok ? diffResult.stdout : '';
180
- res.json({ ok: true, data: { files, diff } });
165
+ const parseNameStatus = (stdout) => stdout
166
+ .split('\n')
167
+ .map((line) => line.trim())
168
+ .filter(Boolean)
169
+ .map((line) => {
170
+ const tab = line.indexOf('\t');
171
+ if (tab === -1)
172
+ return { status: '?', path: line };
173
+ return { status: line.slice(0, tab).trim(), path: line.slice(tab + 1).trim() };
174
+ });
175
+ const stagedStatus = git(wtPath, ['diff', '--cached', '--name-status']);
176
+ const stagedFiles = stagedStatus.ok ? parseNameStatus(stagedStatus.stdout) : [];
177
+ const stagedDiffResult = git(wtPath, ['diff', '--cached']);
178
+ const stagedDiff = stagedDiffResult.ok ? stagedDiffResult.stdout : '';
179
+ const unstagedStatus = git(wtPath, ['diff', '--name-status']);
180
+ const unstagedFiles = unstagedStatus.ok ? parseNameStatus(unstagedStatus.stdout) : [];
181
+ const unstagedDiffResult = git(wtPath, ['diff']);
182
+ const unstagedDiff = unstagedDiffResult.ok ? unstagedDiffResult.stdout : '';
183
+ const graphResult = git(wtPath, ['log', '--graph', '--abbrev-commit', '--format=%h %s (%an, %ar)', '-20']);
184
+ const commitGraph = graphResult.ok ? graphResult.stdout : '';
185
+ res.json({
186
+ ok: true,
187
+ data: {
188
+ staged: { files: stagedFiles, diff: stagedDiff },
189
+ unstaged: { files: unstagedFiles, diff: unstagedDiff },
190
+ commitGraph,
191
+ },
192
+ });
181
193
  }
182
194
  catch (e) {
183
195
  res.status(500).json({
184
196
  ok: false,
185
- error: { code: 'STAGED_DIFF_FAILED', message: errMsg(e) || 'Failed to get staged diff' },
197
+ error: { code: 'DIFF_FAILED', message: errMsg(e) || 'Failed to get diff' },
186
198
  });
187
199
  }
188
200
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fatdoge/wtree",
3
3
  "private": false,
4
- "version": "0.2.2",
4
+ "version": "0.3.0",
5
5
  "description": "CLI + UI tool for managing git worktrees",
6
6
  "keywords": [
7
7
  "git",
@@ -53,6 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@pierre/diffs": "^1.1.21",
56
+ "@pierre/trees": "1.0.0-beta.3",
56
57
  "@vitejs/plugin-react": "^4.4.1",
57
58
  "autoprefixer": "^10.4.21",
58
59
  "chalk": "^5.6.0",
@@ -25,14 +25,24 @@ export type WtuiConfig = {
25
25
  editorCommand?: string
26
26
  }
27
27
 
28
- export type StagedFileChange = {
28
+ export type FileChange = {
29
29
  status: string
30
30
  path: string
31
31
  }
32
32
 
33
- export type WorktreeStagedInfo = {
34
- files: StagedFileChange[]
35
- diff: string
33
+ export type CommitInfo = {
34
+ hash: string
35
+ shortHash: string
36
+ message: string
37
+ author: string
38
+ date: string
39
+ parents: string[]
40
+ }
41
+
42
+ export type WorktreeDiffInfo = {
43
+ staged: { files: FileChange[]; diff: string }
44
+ unstaged: { files: FileChange[]; diff: string }
45
+ commitGraph: string
36
46
  }
37
47
 
38
48
  export type ApiError = {
@@ -1,127 +1,379 @@
1
1
  import { PatchDiff } from '@pierre/diffs/react'
2
+ import { FileTree, useFileTree } from '@pierre/trees/react'
2
3
  import { useTranslation } from 'react-i18next'
3
- import { Copy, ExternalLink } from 'lucide-react'
4
+ import { useMemo, useState, useCallback, useEffect, useRef } from 'react'
5
+ import { Copy, GitCommitHorizontal, ChevronDown, ChevronRight } from 'lucide-react'
4
6
  import { toast } from 'sonner'
5
7
  import Modal from './Modal'
6
8
  import Button from './Button'
7
- import type { StagedFileChange } from '../../shared/wtui-types'
9
+ import { useThemeStore } from '../stores/themeStore'
10
+ import type { FileChange, WorktreeDiffInfo } from '../../shared/wtui-types'
8
11
 
9
12
  type Props = {
10
13
  open: boolean
11
14
  onClose: () => void
12
15
  worktreePath: string
13
- files: StagedFileChange[]
14
- diff: string
16
+ diffInfo: WorktreeDiffInfo | null
15
17
  loading?: boolean
16
18
  }
17
19
 
18
- const STATUS_LABELS: Record<string, { label: string; cls: string }> = {
19
- A: { label: 'A', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400' },
20
- M: { label: 'M', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400' },
21
- D: { label: 'D', cls: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-400' },
22
- R: { label: 'R', cls: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400' },
23
- C: { label: 'C', cls: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400' },
20
+ function buildPatchMap(patch: string): Map<string, string> {
21
+ const map = new Map<string, string>()
22
+ if (!patch) return map
23
+ const lines = patch.split('\n')
24
+ let current: string[] = []
25
+ let currentPath = ''
26
+ const flush = () => {
27
+ if (currentPath && current.length > 0) map.set(currentPath, current.join('\n'))
28
+ current = []
29
+ currentPath = ''
30
+ }
31
+ for (const line of lines) {
32
+ if (line.startsWith('diff --git ')) {
33
+ flush()
34
+ const match = line.match(/^diff --git a\/.+ b\/(.+)$/)
35
+ currentPath = match?.[1] ?? ''
36
+ }
37
+ current.push(line)
38
+ }
39
+ flush()
40
+ return map
41
+ }
42
+
43
+ const GIT_STATUS_MAP: Record<string, 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked'> = {
44
+ A: 'added', M: 'modified', D: 'deleted', R: 'renamed', C: 'added', U: 'untracked', '?': 'untracked',
45
+ }
46
+
47
+ const TREE_STYLES_LIGHT = {
48
+ colorScheme: 'light' as const,
49
+ '--trees-theme-sidebar-bg': '#ffffff',
50
+ '--trees-theme-sidebar-fg': '#1e293b',
51
+ '--trees-theme-sidebar-border': '#e2e8f0',
52
+ '--trees-theme-list-hover-bg': '#f8fafc',
53
+ '--trees-theme-list-active-selection-bg': '#eff6ff',
54
+ '--trees-theme-list-active-selection-fg': '#1e293b',
55
+ '--trees-theme-git-added-fg': '#16a34a',
56
+ '--trees-theme-git-modified-fg': '#2563eb',
57
+ '--trees-theme-git-deleted-fg': '#dc2626',
24
58
  }
25
59
 
26
- function statusMeta(s: string) {
60
+ const TREE_STYLES_DARK = {
61
+ colorScheme: 'dark' as const,
62
+ '--trees-theme-sidebar-bg': '#0f172a',
63
+ '--trees-theme-sidebar-fg': '#e2e8f0',
64
+ '--trees-theme-sidebar-border': '#1e293b',
65
+ '--trees-theme-list-hover-bg': 'rgba(15,23,42,0.4)',
66
+ '--trees-theme-list-active-selection-bg': 'rgba(30,58,138,0.3)',
67
+ '--trees-theme-list-active-selection-fg': '#e2e8f0',
68
+ '--trees-theme-git-added-fg': '#4ade80',
69
+ '--trees-theme-git-modified-fg': '#60a5fa',
70
+ '--trees-theme-git-deleted-fg': '#f87171',
71
+ }
72
+
73
+ type SelectedFile = { path: string; group: 'staged' | 'unstaged' }
74
+
75
+ /* ── Collapsible section wrapper ── */
76
+
77
+ function CollapsibleSection({
78
+ title,
79
+ titleColor,
80
+ icon,
81
+ count,
82
+ defaultOpen = true,
83
+ children,
84
+ }: {
85
+ title: string
86
+ titleColor: string
87
+ icon?: React.ReactNode
88
+ count?: number
89
+ defaultOpen?: boolean
90
+ children: React.ReactNode
91
+ }) {
92
+ const [open, setOpen] = useState(defaultOpen)
27
93
  return (
28
- STATUS_LABELS[s[0]?.toUpperCase()] ?? {
29
- label: s,
30
- cls: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
31
- }
94
+ <div>
95
+ <button
96
+ onClick={() => setOpen(!open)}
97
+ className={`w-full flex items-center gap-1 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide ${titleColor} hover:bg-slate-50 dark:hover:bg-slate-900/40 shrink-0`}
98
+ >
99
+ {open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
100
+ {icon}
101
+ {title}
102
+ {count != null && <span className="ml-auto font-normal text-slate-400 dark:text-slate-500 normal-case">{count}</span>}
103
+ </button>
104
+ {open && children}
105
+ </div>
106
+ )
107
+ }
108
+
109
+ /* ── File tree section ── */
110
+
111
+ function SidebarTree({
112
+ files,
113
+ isDark,
114
+ onSelect,
115
+ group,
116
+ }: {
117
+ files: FileChange[]
118
+ isDark: boolean
119
+ onSelect: (file: SelectedFile) => void
120
+ group: 'staged' | 'unstaged'
121
+ }) {
122
+ const paths = useMemo(() => files.map((f) => f.path), [files])
123
+ const gitStatus = useMemo(
124
+ () => files.map((f) => ({
125
+ path: f.path,
126
+ status: GIT_STATUS_MAP[f.status[0]?.toUpperCase()] ?? ('modified' as const),
127
+ })),
128
+ [files],
129
+ )
130
+
131
+ const { model } = useFileTree({
132
+ paths,
133
+ gitStatus,
134
+ initialExpansion: 'open',
135
+ flattenEmptyDirectories: true,
136
+ density: 'compact',
137
+ icons: { set: 'standard', colored: true },
138
+ initialVisibleRowCount: paths.length + 10,
139
+ onSelectionChange: useCallback(
140
+ (selected: string[]) => {
141
+ const filePath = selected[0]
142
+ if (filePath && files.some((f) => f.path === filePath)) {
143
+ onSelect({ path: filePath, group })
144
+ }
145
+ },
146
+ [files, onSelect, group],
147
+ ),
148
+ })
149
+
150
+ // compact density itemHeight=24. Count files + unique parent dirs for row estimate.
151
+ const dirCount = new Set(files.map((f) => f.path.substring(0, f.path.lastIndexOf('/'))).filter(Boolean)).size
152
+ const treeHeight = Math.max((files.length + dirCount) * 24, 48)
153
+
154
+ return (
155
+ <FileTree
156
+ model={model}
157
+ style={{
158
+ height: `${treeHeight}px`,
159
+ ...(isDark ? TREE_STYLES_DARK : TREE_STYLES_LIGHT),
160
+ }}
161
+ />
32
162
  )
33
163
  }
34
164
 
35
- export default function DiffPreviewModal({ open, onClose, worktreePath, files, diff, loading }: Props) {
165
+ /* ── Commit graph (ASCII from git log --graph) ── */
166
+
167
+ function CommitGraphView({ graphText }: { graphText: string }) {
168
+ if (!graphText) return null
169
+ const lines = graphText.split('\n').filter((l) => l.length > 0)
170
+ return (
171
+ <pre className="font-mono text-[11px] leading-5 px-1 py-1 text-slate-700 dark:text-slate-300 whitespace-pre overflow-x-auto">
172
+ {lines.map((line, i) => {
173
+ // Split line into graph prefix (*/|/\ chars) and commit text
174
+ const match = line.match(/^([* |/\\]+?)(\s[a-f0-9]{7,}.*)$/i)
175
+ if (match) {
176
+ return (
177
+ <div key={i}>
178
+ <span className="text-blue-400 dark:text-blue-500">{match[1]}</span>
179
+ <span>{match[2]}</span>
180
+ </div>
181
+ )
182
+ }
183
+ // Pure graph lines (like |\ or |/)
184
+ return (
185
+ <div key={i} className="text-blue-400 dark:text-blue-500">{line}</div>
186
+ )
187
+ })}
188
+ </pre>
189
+ )
190
+ }
191
+
192
+ /* ── Draggable vertical divider ── */
193
+
194
+ function useDragResize(initialWidth: number, minWidth: number, maxWidth: number) {
195
+ const [width, setWidth] = useState(initialWidth)
196
+ const dragging = useRef(false)
197
+ const startX = useRef(0)
198
+ const startW = useRef(0)
199
+
200
+ const onMouseDown = useCallback((e: React.MouseEvent) => {
201
+ e.preventDefault()
202
+ dragging.current = true
203
+ startX.current = e.clientX
204
+ startW.current = width
205
+
206
+ const onMouseMove = (ev: MouseEvent) => {
207
+ if (!dragging.current) return
208
+ const delta = ev.clientX - startX.current
209
+ setWidth(Math.max(minWidth, Math.min(maxWidth, startW.current + delta)))
210
+ }
211
+ const onMouseUp = () => {
212
+ dragging.current = false
213
+ document.removeEventListener('mousemove', onMouseMove)
214
+ document.removeEventListener('mouseup', onMouseUp)
215
+ document.body.style.cursor = ''
216
+ document.body.style.userSelect = ''
217
+ }
218
+ document.body.style.cursor = 'col-resize'
219
+ document.body.style.userSelect = 'none'
220
+ document.addEventListener('mousemove', onMouseMove)
221
+ document.addEventListener('mouseup', onMouseUp)
222
+ }, [width, minWidth, maxWidth])
223
+
224
+ return { width, onMouseDown }
225
+ }
226
+
227
+ /* ── Main component ── */
228
+
229
+ export default function DiffPreviewModal({ open, onClose, worktreePath, diffInfo, loading }: Props) {
36
230
  const { t } = useTranslation()
231
+ const theme = useThemeStore((s) => s.theme)
232
+ const [selected, setSelected] = useState<SelectedFile | null>(null)
233
+ const { width: sidebarWidth, onMouseDown: onDividerMouseDown } = useDragResize(260, 160, 640)
234
+
235
+ const resolvedThemeType = theme === 'system' ? 'system' : theme === 'dark' ? 'dark' : 'light'
236
+ const isDark =
237
+ theme === 'dark' ||
238
+ (theme === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches)
239
+
240
+ const staged = diffInfo?.staged
241
+ const unstaged = diffInfo?.unstaged
242
+ const commitGraph = diffInfo?.commitGraph ?? ''
243
+ const allDiff = [staged?.diff, unstaged?.diff].filter(Boolean).join('\n')
244
+ const hasChanges = (staged?.files.length ?? 0) > 0 || (unstaged?.files.length ?? 0) > 0
245
+
246
+ const stagedPatchMap = useMemo(() => buildPatchMap(staged?.diff ?? ''), [staged?.diff])
247
+ const unstagedPatchMap = useMemo(() => buildPatchMap(unstaged?.diff ?? ''), [unstaged?.diff])
248
+
249
+ useEffect(() => {
250
+ if (!diffInfo) { setSelected(null); return }
251
+ const first = diffInfo.staged.files[0] ?? diffInfo.unstaged.files[0]
252
+ if (first) {
253
+ setSelected({ path: first.path, group: diffInfo.staged.files[0] ? 'staged' : 'unstaged' })
254
+ } else {
255
+ setSelected(null)
256
+ }
257
+ }, [diffInfo])
258
+
259
+ const selectedPatch = useMemo(() => {
260
+ if (!selected) return null
261
+ const map = selected.group === 'staged' ? stagedPatchMap : unstagedPatchMap
262
+ return map.get(selected.path) ?? null
263
+ }, [selected, stagedPatchMap, unstagedPatchMap])
37
264
 
38
265
  const handleCopy = async () => {
39
266
  try {
40
- await navigator.clipboard.writeText(diff)
267
+ await navigator.clipboard.writeText(selectedPatch || allDiff)
41
268
  toast.success(t('diff.toast.copied'))
42
269
  } catch {
43
270
  toast.error(t('diff.toast.copyFailed'))
44
271
  }
45
272
  }
46
273
 
47
- const handleOpenDiffscom = () => {
48
- navigator.clipboard.writeText(diff).catch(() => {})
49
- window.open('https://diffs.com', '_blank', 'noopener,noreferrer')
50
- toast.info(t('diff.toast.openedDiffscom'))
51
- }
52
-
53
274
  return (
54
275
  <Modal
55
276
  title={t('diff.title')}
56
277
  open={open}
57
278
  onClose={onClose}
58
- size="xl"
279
+ size="full"
59
280
  footer={
60
281
  <>
61
- <Button variant="secondary" size="sm" onClick={handleCopy} disabled={!diff}>
282
+ <Button variant="secondary" size="sm" onClick={handleCopy} disabled={!allDiff}>
62
283
  <Copy className="h-4 w-4" />
63
284
  {t('diff.copyDiff')}
64
285
  </Button>
65
- <Button variant="secondary" size="sm" onClick={handleOpenDiffscom} disabled={!diff}>
66
- <ExternalLink className="h-4 w-4" />
67
- {t('diff.openDiffscom')}
68
- </Button>
69
286
  <Button variant="secondary" size="sm" onClick={onClose}>
70
287
  {t('diff.close')}
71
288
  </Button>
72
289
  </>
73
290
  }
74
291
  >
75
- <div className="space-y-3">
76
- <div className="text-xs font-mono text-slate-500 dark:text-slate-400 truncate">{worktreePath}</div>
292
+ <div className="max-h-[75vh]">
293
+ <div className="text-xs font-mono text-slate-500 dark:text-slate-400 truncate mb-2">{worktreePath}</div>
77
294
 
78
295
  {loading ? (
79
296
  <div className="space-y-2">
80
297
  <div className="h-5 animate-pulse rounded bg-slate-100 dark:bg-slate-900" />
81
298
  <div className="h-5 animate-pulse rounded bg-slate-100 dark:bg-slate-900 w-3/4" />
82
299
  </div>
83
- ) : files.length === 0 ? (
300
+ ) : !hasChanges && !commitGraph ? (
84
301
  <div className="text-sm text-slate-500 dark:text-slate-400">{t('diff.noChanges')}</div>
85
302
  ) : (
86
- <>
87
- <div>
88
- <div className="mb-1 text-xs text-slate-500 dark:text-slate-400">
89
- {t('diff.stagedFiles')} ({files.length})
90
- </div>
91
- <div className="rounded-lg border border-slate-200 dark:border-slate-800 overflow-hidden">
92
- {files.map((f, i) => {
93
- const meta = statusMeta(f.status)
94
- return (
95
- <div
96
- key={i}
97
- className="flex items-center gap-2 px-3 py-1.5 border-b last:border-0 border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-900/40"
303
+ <div className="flex rounded-lg border border-slate-200 dark:border-slate-800 overflow-hidden" style={{ height: 'calc(75vh - 40px)' }}>
304
+ {/* Left sidebar */}
305
+ <div className="shrink-0 flex flex-col bg-white dark:bg-slate-950" style={{ width: `${sidebarWidth}px` }}>
306
+ {/* File trees + commits — scrollable as a whole */}
307
+ <div className="flex-1 overflow-y-auto min-h-0">
308
+ {staged && staged.files.length > 0 && (
309
+ <CollapsibleSection
310
+ title={t('diff.stagedFiles')}
311
+ titleColor="text-emerald-600 dark:text-emerald-400"
312
+ count={staged.files.length}
313
+ >
314
+ <SidebarTree files={staged.files} isDark={isDark} onSelect={setSelected} group="staged" />
315
+ </CollapsibleSection>
316
+ )}
317
+
318
+ {unstaged && unstaged.files.length > 0 && (
319
+ <>
320
+ {staged && staged.files.length > 0 && <div className="border-t border-slate-200 dark:border-slate-800" />}
321
+ <CollapsibleSection
322
+ title={t('diff.unstagedFiles')}
323
+ titleColor="text-amber-600 dark:text-amber-400"
324
+ count={unstaged.files.length}
98
325
  >
99
- <span className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold ${meta.cls}`}>
100
- {meta.label}
101
- </span>
102
- <span className="font-mono text-xs text-slate-800 dark:text-slate-200 truncate">{f.path}</span>
103
- </div>
104
- )
105
- })}
326
+ <SidebarTree files={unstaged.files} isDark={isDark} onSelect={setSelected} group="unstaged" />
327
+ </CollapsibleSection>
328
+ </>
329
+ )}
106
330
  </div>
331
+
332
+ {/* Commits — pinned to bottom */}
333
+ {commitGraph && (
334
+ <div className="shrink-0 border-t border-slate-200 dark:border-slate-800">
335
+ <CollapsibleSection
336
+ title={t('diff.commits')}
337
+ titleColor="text-slate-500 dark:text-slate-400"
338
+ icon={<GitCommitHorizontal className="h-3 w-3" />}
339
+ defaultOpen={!hasChanges}
340
+ >
341
+ <div className="max-h-64 overflow-auto">
342
+ <CommitGraphView graphText={commitGraph} />
343
+ </div>
344
+ </CollapsibleSection>
345
+ </div>
346
+ )}
107
347
  </div>
108
348
 
109
- {diff ? (
110
- <div>
111
- <div className="mb-1 text-xs text-slate-500 dark:text-slate-400">{t('diff.diffPreview')}</div>
112
- <div className="max-h-96 overflow-auto rounded-lg border border-slate-200 dark:border-slate-800">
113
- <PatchDiff
114
- patch={diff}
115
- disableWorkerPool
116
- options={{
117
- theme: { dark: 'github-dark', light: 'github-light' },
118
- themeType: 'system',
119
- }}
120
- />
349
+ {/* Draggable divider */}
350
+ <div
351
+ className="w-1 shrink-0 cursor-col-resize bg-slate-200 dark:bg-slate-800 hover:bg-blue-400 dark:hover:bg-blue-500 transition-colors"
352
+ onMouseDown={onDividerMouseDown}
353
+ />
354
+
355
+ {/* Right panel — diff viewer */}
356
+ <div className="flex-1 min-w-0 overflow-auto bg-white dark:bg-slate-950">
357
+ {selectedPatch ? (
358
+ <PatchDiff
359
+ patch={selectedPatch}
360
+ disableWorkerPool
361
+ options={{
362
+ theme: { dark: 'github-dark', light: 'github-light' },
363
+ themeType: resolvedThemeType,
364
+ }}
365
+ />
366
+ ) : selected ? (
367
+ <div className="flex items-center justify-center h-full text-sm text-slate-400 dark:text-slate-500">
368
+ {t('diff.noDiffContent')}
121
369
  </div>
122
- </div>
123
- ) : null}
124
- </>
370
+ ) : (
371
+ <div className="flex items-center justify-center h-full text-sm text-slate-400 dark:text-slate-500">
372
+ {t('diff.selectFile')}
373
+ </div>
374
+ )}
375
+ </div>
376
+ </div>
125
377
  )}
126
378
  </div>
127
379
  </Modal>
@@ -7,13 +7,15 @@ interface Props {
7
7
  title: string
8
8
  children: ReactNode
9
9
  footer?: ReactNode
10
- size?: 'md' | 'lg' | 'xl'
10
+ size?: 'md' | 'lg' | 'xl' | '2xl' | 'full'
11
11
  }
12
12
 
13
13
  const SIZE_CLS: Record<string, string> = {
14
14
  md: 'max-w-md',
15
15
  lg: 'max-w-2xl',
16
16
  xl: 'max-w-4xl',
17
+ '2xl': 'max-w-6xl',
18
+ full: 'max-w-[calc(100vw-2rem)]',
17
19
  }
18
20
 
19
21
  export default function Modal({ open, onClose, title, children, footer, size = 'md' }: Props) {
@@ -129,18 +129,20 @@
129
129
  }
130
130
  },
131
131
  "diff": {
132
- "title": "Staged Changes",
133
- "viewStaged": "Staged Diff",
134
- "stagedFiles": "Staged files",
132
+ "title": "Changes",
133
+ "viewStaged": "Diff",
134
+ "stagedFiles": "Staged",
135
+ "unstagedFiles": "Unstaged",
135
136
  "diffPreview": "Diff preview",
136
- "noChanges": "No staged changes in this worktree.",
137
+ "noChanges": "No changes in this worktree.",
138
+ "selectFile": "Select a file to view diff",
139
+ "noDiffContent": "No diff content for this file",
140
+ "commits": "Commits",
137
141
  "copyDiff": "Copy Diff",
138
- "openDiffscom": "Open in diffs.com",
139
142
  "close": "Close",
140
143
  "toast": {
141
144
  "copied": "Diff copied to clipboard",
142
- "copyFailed": "Failed to copy diff",
143
- "openedDiffscom": "Diff copied — paste it in diffs.com"
145
+ "copyFailed": "Failed to copy diff"
144
146
  }
145
147
  },
146
148
  "helpPage": {
@@ -129,18 +129,20 @@
129
129
  }
130
130
  },
131
131
  "diff": {
132
- "title": "暂存区变动",
133
- "viewStaged": "暂存 Diff",
134
- "stagedFiles": "暂存文件",
132
+ "title": "变更",
133
+ "viewStaged": "Diff",
134
+ "stagedFiles": "已暂存",
135
+ "unstagedFiles": "未暂存",
135
136
  "diffPreview": "Diff 预览",
136
- "noChanges": "该 worktree 暂存区没有变动。",
137
+ "noChanges": "该 worktree 没有变动。",
138
+ "selectFile": "选择文件查看 diff",
139
+ "noDiffContent": "该文件没有 diff 内容",
140
+ "commits": "提交记录",
137
141
  "copyDiff": "复制 Diff",
138
- "openDiffscom": "在 diffs.com 中查看",
139
142
  "close": "关闭",
140
143
  "toast": {
141
144
  "copied": "Diff 已复制到剪贴板",
142
- "copyFailed": "复制 Diff 失败",
143
- "openedDiffscom": "Diff 已复制 — 请粘贴到 diffs.com"
145
+ "copyFailed": "复制 Diff 失败"
144
146
  }
145
147
  },
146
148
  "helpPage": {