@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/api/routes/worktrees.ts +34 -19
- package/dist/assets/index-bLnnvz_q.css +1 -0
- package/dist/assets/index-sP_n0D3A.js +2246 -0
- package/dist/index.html +2 -2
- package/dist-node/api/routes/worktrees.js +29 -17
- package/package.json +2 -1
- package/shared/wtui-types.ts +14 -4
- package/src/components/DiffPreviewModal.tsx +318 -66
- package/src/components/Modal.tsx +3 -1
- package/src/i18n/locales/en.json +9 -7
- package/src/i18n/locales/zh.json +9 -7
- package/src/pages/Worktrees.tsx +6 -13
- package/src/stores/worktreeStore.ts +3 -3
- package/dist/assets/index-C7vu17w3.js +0 -413
- package/dist/assets/index-DsCX4t5o.css +0 -1
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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
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: '
|
|
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.
|
|
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",
|
package/shared/wtui-types.ts
CHANGED
|
@@ -25,14 +25,24 @@ export type WtuiConfig = {
|
|
|
25
25
|
editorCommand?: string
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
export type
|
|
28
|
+
export type FileChange = {
|
|
29
29
|
status: string
|
|
30
30
|
path: string
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export type
|
|
34
|
-
|
|
35
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
14
|
-
diff: string
|
|
16
|
+
diffInfo: WorktreeDiffInfo | null
|
|
15
17
|
loading?: boolean
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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(
|
|
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="
|
|
279
|
+
size="full"
|
|
59
280
|
footer={
|
|
60
281
|
<>
|
|
61
|
-
<Button variant="secondary" size="sm" onClick={handleCopy} disabled={!
|
|
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="
|
|
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
|
-
) :
|
|
300
|
+
) : !hasChanges && !commitGraph ? (
|
|
84
301
|
<div className="text-sm text-slate-500 dark:text-slate-400">{t('diff.noChanges')}</div>
|
|
85
302
|
) : (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
<
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
{
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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>
|
package/src/components/Modal.tsx
CHANGED
|
@@ -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) {
|
package/src/i18n/locales/en.json
CHANGED
|
@@ -129,18 +129,20 @@
|
|
|
129
129
|
}
|
|
130
130
|
},
|
|
131
131
|
"diff": {
|
|
132
|
-
"title": "
|
|
133
|
-
"viewStaged": "
|
|
134
|
-
"stagedFiles": "Staged
|
|
132
|
+
"title": "Changes",
|
|
133
|
+
"viewStaged": "Diff",
|
|
134
|
+
"stagedFiles": "Staged",
|
|
135
|
+
"unstagedFiles": "Unstaged",
|
|
135
136
|
"diffPreview": "Diff preview",
|
|
136
|
-
"noChanges": "No
|
|
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": {
|
package/src/i18n/locales/zh.json
CHANGED
|
@@ -129,18 +129,20 @@
|
|
|
129
129
|
}
|
|
130
130
|
},
|
|
131
131
|
"diff": {
|
|
132
|
-
"title": "
|
|
133
|
-
"viewStaged": "
|
|
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": {
|