@fatdoge/wtree 0.1.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/README.en.md +113 -0
- package/README.md +136 -0
- package/api/app.ts +19 -0
- package/api/cli/wtree.ts +809 -0
- package/api/core/config.ts +26 -0
- package/api/core/exec.ts +55 -0
- package/api/core/git.ts +35 -0
- package/api/core/id.ts +8 -0
- package/api/core/open.ts +58 -0
- package/api/core/worktree.test.ts +33 -0
- package/api/core/worktree.ts +72 -0
- package/api/createApiApp.ts +33 -0
- package/api/index.ts +9 -0
- package/api/routes/worktrees.ts +255 -0
- package/api/server.ts +34 -0
- package/api/ui/startUiDev.ts +82 -0
- package/dist/assets/index-D9inyPb3.js +179 -0
- package/dist/assets/index-W34LSHWF.css +1 -0
- package/dist/favicon.svg +4 -0
- package/dist/index.html +354 -0
- package/dist-node/api/app.js +17 -0
- package/dist-node/api/cli/wtree.js +722 -0
- package/dist-node/api/cli/wtui.js +722 -0
- package/dist-node/api/core/config.js +21 -0
- package/dist-node/api/core/exec.js +24 -0
- package/dist-node/api/core/git.js +24 -0
- package/dist-node/api/core/id.js +6 -0
- package/dist-node/api/core/open.js +51 -0
- package/dist-node/api/core/worktree.js +58 -0
- package/dist-node/api/core/worktree.test.js +30 -0
- package/dist-node/api/createApiApp.js +26 -0
- package/dist-node/api/routes/worktrees.js +213 -0
- package/dist-node/api/server.js +29 -0
- package/dist-node/api/ui/startUiDev.js +65 -0
- package/dist-node/shared/wtui-types.js +1 -0
- package/index.html +24 -0
- package/package.json +89 -0
- package/postcss.config.js +10 -0
- package/shared/wtui-types.ts +36 -0
- package/src/App.tsx +28 -0
- package/src/assets/react.svg +1 -0
- package/src/components/Button.tsx +34 -0
- package/src/components/Empty.tsx +8 -0
- package/src/components/Input.tsx +16 -0
- package/src/components/Modal.tsx +33 -0
- package/src/components/ToastHost.tsx +42 -0
- package/src/hooks/useTheme.ts +29 -0
- package/src/i18n/index.ts +22 -0
- package/src/i18n/locales/en.json +145 -0
- package/src/i18n/locales/zh.json +145 -0
- package/src/index.css +24 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +11 -0
- package/src/pages/CreateWorktree.tsx +181 -0
- package/src/pages/HelpPage.tsx +67 -0
- package/src/pages/Home.tsx +3 -0
- package/src/pages/SettingsPage.tsx +218 -0
- package/src/pages/Worktrees.tsx +354 -0
- package/src/stores/themeStore.ts +44 -0
- package/src/stores/toastStore.ts +29 -0
- package/src/stores/worktreeStore.ts +93 -0
- package/src/utils/api.ts +36 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +13 -0
- package/vite.config.ts +46 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { Copy, ExternalLink, Plus, RefreshCw, Settings, Trash2, Lock, Unlock, Code } from 'lucide-react'
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
3
|
+
import { Link } from 'react-router-dom'
|
|
4
|
+
import { useTranslation } from 'react-i18next'
|
|
5
|
+
import Button from '@/components/Button'
|
|
6
|
+
import Modal from '@/components/Modal'
|
|
7
|
+
import { useToastStore } from '@/stores/toastStore'
|
|
8
|
+
import { useWorktreeStore } from '@/stores/worktreeStore'
|
|
9
|
+
|
|
10
|
+
function truncatePath(p: string) {
|
|
11
|
+
if (p.length <= 70) return p
|
|
12
|
+
return `${p.slice(0, 28)}…${p.slice(-38)}`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function Worktrees() {
|
|
16
|
+
const { t } = useTranslation()
|
|
17
|
+
const loading = useWorktreeStore((s) => s.loading)
|
|
18
|
+
const items = useWorktreeStore((s) => s.items)
|
|
19
|
+
const selectedId = useWorktreeStore((s) => s.selectedId)
|
|
20
|
+
const refresh = useWorktreeStore((s) => s.refresh)
|
|
21
|
+
const select = useWorktreeStore((s) => s.select)
|
|
22
|
+
const remove = useWorktreeStore((s) => s.remove)
|
|
23
|
+
const open = useWorktreeStore((s) => s.open)
|
|
24
|
+
const lock = useWorktreeStore((s) => s.lock)
|
|
25
|
+
const unlock = useWorktreeStore((s) => s.unlock)
|
|
26
|
+
const toast = useToastStore((s) => s.push)
|
|
27
|
+
|
|
28
|
+
const [removeId, setRemoveId] = useState<string | null>(null)
|
|
29
|
+
const [forceDelete, setForceDelete] = useState(false)
|
|
30
|
+
const [isDeleting, setIsDeleting] = useState(false)
|
|
31
|
+
const selected = useMemo(() => items.find((x) => x.id === selectedId), [items, selectedId])
|
|
32
|
+
const toRemove = useMemo(() => items.find((x) => x.id === removeId), [items, removeId])
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
refresh()
|
|
36
|
+
}, [refresh])
|
|
37
|
+
|
|
38
|
+
const closeModal = () => {
|
|
39
|
+
setRemoveId(null)
|
|
40
|
+
setForceDelete(false)
|
|
41
|
+
setIsDeleting(false)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const handleDelete = async () => {
|
|
45
|
+
if (!removeId) return
|
|
46
|
+
setIsDeleting(true)
|
|
47
|
+
try {
|
|
48
|
+
const res = await remove(removeId, forceDelete)
|
|
49
|
+
if (res.success) {
|
|
50
|
+
toast({ type: 'success', title: forceDelete ? t('worktrees.toast.forceDeleteSuccess') : t('worktrees.toast.deleteSuccess') })
|
|
51
|
+
closeModal()
|
|
52
|
+
} else {
|
|
53
|
+
const msg = res.error || ''
|
|
54
|
+
if (msg.toLowerCase().includes('force') || msg.includes('modified') || msg.includes('untracked')) {
|
|
55
|
+
setForceDelete(true)
|
|
56
|
+
toast({
|
|
57
|
+
type: 'error',
|
|
58
|
+
title: t('worktrees.toast.deleteFailed'),
|
|
59
|
+
detail: t('worktrees.toast.deleteWarning'),
|
|
60
|
+
})
|
|
61
|
+
} else {
|
|
62
|
+
toast({ type: 'error', title: t('worktrees.toast.deleteFailed'), detail: msg })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch (e: unknown) {
|
|
66
|
+
const msg = e instanceof Error ? e.message : String(e)
|
|
67
|
+
toast({ type: 'error', title: t('worktrees.toast.deleteFailed'), detail: msg })
|
|
68
|
+
} finally {
|
|
69
|
+
setIsDeleting(false)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const onCopy = async (text: string) => {
|
|
74
|
+
try {
|
|
75
|
+
await navigator.clipboard.writeText(text)
|
|
76
|
+
toast({ type: 'success', title: t('worktrees.toast.copySuccess') })
|
|
77
|
+
} catch {
|
|
78
|
+
toast({ type: 'error', title: t('worktrees.toast.copyFailed') })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className="mx-auto w-full max-w-screen-xl px-4 py-6">
|
|
84
|
+
<div className="flex items-start justify-between gap-4">
|
|
85
|
+
<div className="min-w-0">
|
|
86
|
+
<div className="text-lg font-semibold text-slate-900 dark:text-slate-100">{t('worktrees.title')}</div>
|
|
87
|
+
<div className="mt-1 truncate text-xs text-slate-500 dark:text-slate-400">{t('worktrees.subtitle')}</div>
|
|
88
|
+
</div>
|
|
89
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
90
|
+
<Link to="/settings">
|
|
91
|
+
<Button variant="ghost" size="sm">
|
|
92
|
+
<Settings className="h-4 w-4" />
|
|
93
|
+
</Button>
|
|
94
|
+
</Link>
|
|
95
|
+
<Button variant="secondary" size="sm" onClick={refresh} disabled={loading}>
|
|
96
|
+
<RefreshCw className={loading ? 'h-4 w-4 animate-spin' : 'h-4 w-4'} />
|
|
97
|
+
</Button>
|
|
98
|
+
<Link to="/create">
|
|
99
|
+
<Button variant="primary" size="sm">
|
|
100
|
+
<Plus className="h-4 w-4" />
|
|
101
|
+
</Button>
|
|
102
|
+
</Link>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div className="mt-5 grid grid-cols-12 gap-4">
|
|
107
|
+
<div className="col-span-12 lg:col-span-9 order-2 lg:order-1">
|
|
108
|
+
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-950">
|
|
109
|
+
<div className="hidden md:grid grid-cols-12 gap-2 border-b border-slate-100 dark:border-slate-800 px-3 py-2 text-xs text-slate-500 dark:text-slate-400">
|
|
110
|
+
<div className="col-span-5">{t('worktrees.table.path')}</div>
|
|
111
|
+
<div className="col-span-3">{t('worktrees.table.branch')}</div>
|
|
112
|
+
<div className="col-span-2">{t('worktrees.table.flags')}</div>
|
|
113
|
+
<div className="col-span-2 text-right">{t('worktrees.table.actions')}</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{loading ? (
|
|
117
|
+
<div className="p-4">
|
|
118
|
+
<div className="h-10 animate-pulse rounded-lg bg-slate-100 dark:bg-slate-900" />
|
|
119
|
+
</div>
|
|
120
|
+
) : items.length === 0 ? (
|
|
121
|
+
<div className="p-6 text-sm text-slate-500 dark:text-slate-300">{t('worktrees.empty')}</div>
|
|
122
|
+
) : (
|
|
123
|
+
<div className="max-h-[520px] overflow-auto">
|
|
124
|
+
{items.map((wt) => {
|
|
125
|
+
const active = wt.id === selectedId
|
|
126
|
+
const flags = [
|
|
127
|
+
wt.isMain ? 'Main' : null,
|
|
128
|
+
wt.isLocked ? 'Locked' : null,
|
|
129
|
+
].filter(Boolean)
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
key={wt.id}
|
|
134
|
+
className={
|
|
135
|
+
active
|
|
136
|
+
? 'flex flex-col md:grid md:grid-cols-12 gap-2 bg-slate-100 dark:bg-slate-900/60 px-3 py-3 md:py-2 text-sm border-b md:border-b-0 border-slate-100 dark:border-slate-800/50'
|
|
137
|
+
: 'flex flex-col md:grid md:grid-cols-12 gap-2 px-3 py-3 md:py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-900/40 border-b md:border-b-0 border-slate-100 dark:border-slate-800/50'
|
|
138
|
+
}
|
|
139
|
+
onClick={() => select(wt.id)}
|
|
140
|
+
role="button"
|
|
141
|
+
tabIndex={0}
|
|
142
|
+
>
|
|
143
|
+
<div className="col-span-5 min-w-0">
|
|
144
|
+
<div className="md:hidden text-xs text-slate-500 dark:text-slate-400 mb-0.5">{t('worktrees.table.path')}</div>
|
|
145
|
+
<div className="truncate font-mono text-xs text-slate-900 dark:text-slate-100" title={wt.path}>
|
|
146
|
+
{truncatePath(wt.path)}
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
<div className="col-span-3 min-w-0 mt-2 md:mt-0">
|
|
150
|
+
<div className="md:hidden flex items-center gap-2 mb-0.5">
|
|
151
|
+
<span className="text-xs text-slate-500 dark:text-slate-400">{t('worktrees.table.branch')}</span>
|
|
152
|
+
<span className="font-mono text-xs text-slate-500">{wt.head.slice(0, 10)}</span>
|
|
153
|
+
</div>
|
|
154
|
+
<div className="truncate text-slate-900 dark:text-slate-100">
|
|
155
|
+
{wt.branch ? wt.branch : 'HEAD'}
|
|
156
|
+
</div>
|
|
157
|
+
<div className="hidden md:block truncate font-mono text-xs text-slate-500">{wt.head.slice(0, 10)}</div>
|
|
158
|
+
</div>
|
|
159
|
+
<div className="col-span-2 flex flex-wrap items-center gap-1 mt-2 md:mt-0">
|
|
160
|
+
{flags.length === 0 ? (
|
|
161
|
+
<span className="text-xs text-slate-400 dark:text-slate-500 hidden md:inline">—</span>
|
|
162
|
+
) : (
|
|
163
|
+
flags.map((f) => (
|
|
164
|
+
<span
|
|
165
|
+
key={f}
|
|
166
|
+
className="rounded border border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-900 px-1.5 py-0.5 text-[11px] text-slate-600 dark:text-slate-300"
|
|
167
|
+
>
|
|
168
|
+
{f}
|
|
169
|
+
</span>
|
|
170
|
+
))
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
<div className="col-span-2 mt-3 md:mt-0 flex md:justify-end gap-1">
|
|
174
|
+
<Button
|
|
175
|
+
variant="ghost"
|
|
176
|
+
size="sm"
|
|
177
|
+
onClick={(e) => {
|
|
178
|
+
e.stopPropagation()
|
|
179
|
+
open(wt.id).then((ok) =>
|
|
180
|
+
toast({
|
|
181
|
+
type: ok ? 'success' : 'error',
|
|
182
|
+
title: ok ? t('worktrees.toast.folderSuccess') : t('worktrees.toast.folderFailed'),
|
|
183
|
+
}),
|
|
184
|
+
)
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
<ExternalLink className="h-4 w-4" />
|
|
188
|
+
</Button>
|
|
189
|
+
<Button
|
|
190
|
+
variant="ghost"
|
|
191
|
+
size="sm"
|
|
192
|
+
onClick={(e) => {
|
|
193
|
+
e.stopPropagation()
|
|
194
|
+
onCopy(wt.path)
|
|
195
|
+
}}
|
|
196
|
+
title={t('worktrees.actions.copy')}
|
|
197
|
+
>
|
|
198
|
+
<Copy className="h-4 w-4" />
|
|
199
|
+
</Button>
|
|
200
|
+
<Button
|
|
201
|
+
variant="ghost"
|
|
202
|
+
size="sm"
|
|
203
|
+
disabled={wt.isMain}
|
|
204
|
+
onClick={(e) => {
|
|
205
|
+
e.stopPropagation()
|
|
206
|
+
setRemoveId(wt.id)
|
|
207
|
+
}}
|
|
208
|
+
title={t('worktrees.actions.delete')}
|
|
209
|
+
>
|
|
210
|
+
<Trash2 className="h-4 w-4" />
|
|
211
|
+
</Button>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
)
|
|
215
|
+
})}
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<div className="col-span-12 lg:col-span-3 order-1 lg:order-2 mb-4 lg:mb-0">
|
|
222
|
+
<div className="rounded-xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-950 p-4">
|
|
223
|
+
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">{t('worktrees.details')}</div>
|
|
224
|
+
{selected ? (
|
|
225
|
+
<div className="mt-3 space-y-2 text-xs">
|
|
226
|
+
<div>
|
|
227
|
+
<div className="text-slate-500 dark:text-slate-400">{t('worktrees.detailsPanel.path')}</div>
|
|
228
|
+
<div className="mt-1 break-all font-mono text-slate-900 dark:text-slate-100">{selected.path}</div>
|
|
229
|
+
</div>
|
|
230
|
+
<div className="grid grid-cols-2 gap-2">
|
|
231
|
+
<div>
|
|
232
|
+
<div className="text-slate-500 dark:text-slate-400">{t('worktrees.detailsPanel.branch')}</div>
|
|
233
|
+
<div className="mt-1 truncate text-slate-900 dark:text-slate-100" title={selected.branch || 'HEAD'}>{selected.branch || 'HEAD'}</div>
|
|
234
|
+
</div>
|
|
235
|
+
<div>
|
|
236
|
+
<div className="text-slate-500 dark:text-slate-400">{t('worktrees.detailsPanel.main')}</div>
|
|
237
|
+
<div className="mt-1 text-slate-900 dark:text-slate-100">{selected.isMain ? t('worktrees.detailsPanel.yes') : t('worktrees.detailsPanel.no')}</div>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
<div>
|
|
241
|
+
<div className="text-slate-500 dark:text-slate-400">{t('worktrees.detailsPanel.head')}</div>
|
|
242
|
+
<div className="mt-1 break-all font-mono text-slate-900 dark:text-slate-100">{selected.head}</div>
|
|
243
|
+
</div>
|
|
244
|
+
<div className="flex flex-col sm:flex-row flex-wrap gap-2 pt-2">
|
|
245
|
+
<Button className="w-full sm:w-auto" variant="secondary" size="sm" onClick={() => onCopy(selected.path)}>
|
|
246
|
+
<Copy className="h-4 w-4" />
|
|
247
|
+
{t('worktrees.actions.copy')}
|
|
248
|
+
</Button>
|
|
249
|
+
<Button
|
|
250
|
+
className="w-full sm:w-auto"
|
|
251
|
+
variant="secondary"
|
|
252
|
+
size="sm"
|
|
253
|
+
onClick={() => {
|
|
254
|
+
const action = selected.isLocked ? unlock : lock
|
|
255
|
+
const msg = selected.isLocked ? t('worktrees.toast.unlockSuccess') : t('worktrees.toast.lockSuccess')
|
|
256
|
+
const msgFail = selected.isLocked ? t('worktrees.toast.unlockFailed') : t('worktrees.toast.lockFailed')
|
|
257
|
+
action(selected.id).then((ok) =>
|
|
258
|
+
toast({
|
|
259
|
+
type: ok ? 'success' : 'error',
|
|
260
|
+
title: ok ? msg : msgFail,
|
|
261
|
+
}),
|
|
262
|
+
)
|
|
263
|
+
}}
|
|
264
|
+
>
|
|
265
|
+
{selected.isLocked ? <Unlock className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
|
|
266
|
+
{selected.isLocked ? t('worktrees.actions.unlock') : t('worktrees.actions.lock')}
|
|
267
|
+
</Button>
|
|
268
|
+
<Button
|
|
269
|
+
className="w-full sm:w-auto"
|
|
270
|
+
variant="primary"
|
|
271
|
+
size="sm"
|
|
272
|
+
onClick={() =>
|
|
273
|
+
open(selected.id, 'editor').then((ok) =>
|
|
274
|
+
toast({
|
|
275
|
+
type: ok ? 'success' : 'error',
|
|
276
|
+
title: ok ? t('worktrees.toast.ideSuccess') : t('worktrees.toast.ideFailed'),
|
|
277
|
+
}),
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
>
|
|
281
|
+
<Code className="h-4 w-4" />
|
|
282
|
+
{t('worktrees.actions.ide')}
|
|
283
|
+
</Button>
|
|
284
|
+
<Button
|
|
285
|
+
className="w-full sm:w-auto"
|
|
286
|
+
variant="secondary"
|
|
287
|
+
size="sm"
|
|
288
|
+
onClick={() =>
|
|
289
|
+
open(selected.id, 'folder').then((ok) =>
|
|
290
|
+
toast({
|
|
291
|
+
type: ok ? 'success' : 'error',
|
|
292
|
+
title: ok ? t('worktrees.toast.folderSuccess') : t('worktrees.toast.folderFailed'),
|
|
293
|
+
}),
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
>
|
|
297
|
+
<ExternalLink className="h-4 w-4" />
|
|
298
|
+
{t('worktrees.actions.folder')}
|
|
299
|
+
</Button>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
) : (
|
|
303
|
+
<div className="mt-3 text-xs text-slate-500 dark:text-slate-400">{t('worktrees.selectToView')}</div>
|
|
304
|
+
)}
|
|
305
|
+
</div>
|
|
306
|
+
|
|
307
|
+
<div className="mt-3 rounded-xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-950 p-4">
|
|
308
|
+
<div className="text-sm font-semibold text-slate-900 dark:text-slate-100">{t('worktrees.help.title')}</div>
|
|
309
|
+
<div className="mt-2 text-xs text-slate-500 dark:text-slate-400">{t('worktrees.help.desc')}</div>
|
|
310
|
+
<div className="mt-3">
|
|
311
|
+
<Link to="/help">
|
|
312
|
+
<Button variant="secondary" size="sm">{t('worktrees.help.viewCommands')}</Button>
|
|
313
|
+
</Link>
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
<Modal
|
|
320
|
+
title={forceDelete ? t('worktrees.deleteModal.forceTitle') : t('worktrees.deleteModal.title')}
|
|
321
|
+
open={Boolean(removeId)}
|
|
322
|
+
onClose={closeModal}
|
|
323
|
+
footer={
|
|
324
|
+
<>
|
|
325
|
+
<Button variant="secondary" size="sm" onClick={closeModal} disabled={isDeleting}>
|
|
326
|
+
{t('worktrees.deleteModal.cancel')}
|
|
327
|
+
</Button>
|
|
328
|
+
<Button
|
|
329
|
+
variant="danger"
|
|
330
|
+
size="sm"
|
|
331
|
+
onClick={handleDelete}
|
|
332
|
+
disabled={isDeleting}
|
|
333
|
+
>
|
|
334
|
+
{isDeleting ? t('worktrees.deleteModal.deleting') : forceDelete ? t('worktrees.deleteModal.forceConfirm') : t('worktrees.deleteModal.confirm')}
|
|
335
|
+
</Button>
|
|
336
|
+
</>
|
|
337
|
+
}
|
|
338
|
+
>
|
|
339
|
+
<div className="text-xs text-slate-600 dark:text-slate-300">{t('worktrees.deleteModal.desc')}</div>
|
|
340
|
+
<div className="mt-2 rounded-md border border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-900 px-3 py-2 font-mono text-xs text-slate-900 dark:text-slate-100">
|
|
341
|
+
{toRemove ? toRemove.path : '—'}
|
|
342
|
+
</div>
|
|
343
|
+
{forceDelete ? (
|
|
344
|
+
<div className="mt-2 rounded bg-rose-100 dark:bg-rose-500/10 px-2 py-1 text-xs text-rose-600 dark:text-rose-400">
|
|
345
|
+
{t('worktrees.deleteModal.warning')}
|
|
346
|
+
</div>
|
|
347
|
+
) : (
|
|
348
|
+
<div className="mt-2 text-xs text-slate-500 dark:text-slate-400">{t('worktrees.deleteModal.normalNote')}</div>
|
|
349
|
+
)}
|
|
350
|
+
</Modal>
|
|
351
|
+
</div>
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { create } from 'zustand'
|
|
2
|
+
|
|
3
|
+
export type Theme = 'light' | 'dark' | 'system'
|
|
4
|
+
|
|
5
|
+
type ThemeState = {
|
|
6
|
+
theme: Theme
|
|
7
|
+
setTheme: (t: Theme) => void
|
|
8
|
+
initTheme: () => void
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function applyTheme(theme: Theme) {
|
|
12
|
+
const root = window.document.documentElement
|
|
13
|
+
root.classList.remove('light', 'dark')
|
|
14
|
+
|
|
15
|
+
if (theme === 'system') {
|
|
16
|
+
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
17
|
+
root.classList.add(systemTheme)
|
|
18
|
+
} else {
|
|
19
|
+
root.classList.add(theme)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const useThemeStore = create<ThemeState>((set) => ({
|
|
24
|
+
theme: (localStorage.getItem('wtui-theme') as Theme) || 'system',
|
|
25
|
+
setTheme: (t) => {
|
|
26
|
+
localStorage.setItem('wtui-theme', t)
|
|
27
|
+
set({ theme: t })
|
|
28
|
+
applyTheme(t)
|
|
29
|
+
},
|
|
30
|
+
initTheme: () => {
|
|
31
|
+
const t = (localStorage.getItem('wtui-theme') as Theme) || 'system'
|
|
32
|
+
applyTheme(t)
|
|
33
|
+
|
|
34
|
+
// Listen for system theme changes
|
|
35
|
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
|
36
|
+
const handler = () => {
|
|
37
|
+
const currentTheme = useThemeStore.getState().theme
|
|
38
|
+
if (currentTheme === 'system') {
|
|
39
|
+
applyTheme('system')
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
mediaQuery.addEventListener('change', handler)
|
|
43
|
+
},
|
|
44
|
+
}))
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { create } from 'zustand'
|
|
2
|
+
|
|
3
|
+
export type ToastItem = {
|
|
4
|
+
id: string
|
|
5
|
+
type: 'success' | 'error' | 'info'
|
|
6
|
+
title: string
|
|
7
|
+
detail?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type ToastState = {
|
|
11
|
+
items: ToastItem[]
|
|
12
|
+
push: (t: Omit<ToastItem, 'id'>) => void
|
|
13
|
+
remove: (id: string) => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function id() {
|
|
17
|
+
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const useToastStore = create<ToastState>((set, get) => ({
|
|
21
|
+
items: [],
|
|
22
|
+
push: (t) => {
|
|
23
|
+
const item: ToastItem = { id: id(), ...t }
|
|
24
|
+
set((s) => ({ items: [item, ...s.items].slice(0, 5) }))
|
|
25
|
+
setTimeout(() => get().remove(item.id), 3800)
|
|
26
|
+
},
|
|
27
|
+
remove: (id) => set((s) => ({ items: s.items.filter((x) => x.id !== id) })),
|
|
28
|
+
}))
|
|
29
|
+
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { create } from 'zustand'
|
|
2
|
+
import type { CreateWorktreeRequest, WorktreeItem } from '../../shared/wtui-types'
|
|
3
|
+
import { apiDelete, apiGet, apiPost } from '@/utils/api'
|
|
4
|
+
|
|
5
|
+
type WorktreeState = {
|
|
6
|
+
loading: boolean
|
|
7
|
+
items: WorktreeItem[]
|
|
8
|
+
selectedId?: string
|
|
9
|
+
refresh: () => Promise<void>
|
|
10
|
+
select: (id?: string) => void
|
|
11
|
+
create: (req: CreateWorktreeRequest) => Promise<WorktreeItem | null>
|
|
12
|
+
remove: (id: string, force?: boolean) => Promise<{ success: boolean; error?: string }>
|
|
13
|
+
open: (id: string, type?: 'folder' | 'editor') => Promise<boolean>
|
|
14
|
+
lock: (id: string) => Promise<boolean>
|
|
15
|
+
unlock: (id: string) => Promise<boolean>
|
|
16
|
+
prune: () => Promise<boolean>
|
|
17
|
+
branches: string[]
|
|
18
|
+
fetchBranches: () => Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const useWorktreeStore = create<WorktreeState>((set, get) => ({
|
|
22
|
+
loading: false,
|
|
23
|
+
items: [],
|
|
24
|
+
branches: [],
|
|
25
|
+
selectedId: undefined,
|
|
26
|
+
select: (id) => set({ selectedId: id }),
|
|
27
|
+
refresh: async () => {
|
|
28
|
+
set({ loading: true })
|
|
29
|
+
try {
|
|
30
|
+
const r = await apiGet<WorktreeItem[]>('/api/worktrees')
|
|
31
|
+
if (r.ok) {
|
|
32
|
+
set({ items: r.data })
|
|
33
|
+
const sel = get().selectedId
|
|
34
|
+
if (sel && !r.data.some((x) => x.id === sel)) {
|
|
35
|
+
set({ selectedId: undefined })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} finally {
|
|
39
|
+
set({ loading: false })
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
create: async (req) => {
|
|
43
|
+
const r = await apiPost<WorktreeItem, CreateWorktreeRequest>('/api/worktrees', req)
|
|
44
|
+
if (!r.ok) return null
|
|
45
|
+
await get().refresh()
|
|
46
|
+
return r.data
|
|
47
|
+
},
|
|
48
|
+
remove: async (id, force) => {
|
|
49
|
+
const url = force ? `/api/worktrees/${encodeURIComponent(id)}?force=1` : `/api/worktrees/${encodeURIComponent(id)}`
|
|
50
|
+
const r = await apiDelete<{ removed: true }>(url)
|
|
51
|
+
if (!r.ok) {
|
|
52
|
+
const err = (r as { error: { message: string; details?: string } }).error
|
|
53
|
+
return { success: false, error: err.details || err.message }
|
|
54
|
+
}
|
|
55
|
+
await get().refresh()
|
|
56
|
+
return { success: true }
|
|
57
|
+
},
|
|
58
|
+
open: async (id, type) => {
|
|
59
|
+
const r = await apiPost<{ launched: true }, { type?: 'folder' | 'editor' }>(
|
|
60
|
+
`/api/worktrees/${encodeURIComponent(id)}/open`,
|
|
61
|
+
{ type: type || 'folder' },
|
|
62
|
+
)
|
|
63
|
+
return r.ok
|
|
64
|
+
},
|
|
65
|
+
lock: async (id) => {
|
|
66
|
+
const r = await apiPost<{ locked: true }, Record<string, never>>(
|
|
67
|
+
`/api/worktrees/${encodeURIComponent(id)}/lock`,
|
|
68
|
+
{},
|
|
69
|
+
)
|
|
70
|
+
if (!r.ok) return false
|
|
71
|
+
await get().refresh()
|
|
72
|
+
return true
|
|
73
|
+
},
|
|
74
|
+
unlock: async (id) => {
|
|
75
|
+
const r = await apiPost<{ unlocked: true }, Record<string, never>>(
|
|
76
|
+
`/api/worktrees/${encodeURIComponent(id)}/unlock`,
|
|
77
|
+
{},
|
|
78
|
+
)
|
|
79
|
+
if (!r.ok) return false
|
|
80
|
+
await get().refresh()
|
|
81
|
+
return true
|
|
82
|
+
},
|
|
83
|
+
prune: async () => {
|
|
84
|
+
const r = await apiPost<{ pruned: true }, Record<string, never>>('/api/worktrees/prune', {})
|
|
85
|
+
if (!r.ok) return false
|
|
86
|
+
await get().refresh()
|
|
87
|
+
return true
|
|
88
|
+
},
|
|
89
|
+
fetchBranches: async () => {
|
|
90
|
+
const r = await apiGet<string[]>('/api/branches')
|
|
91
|
+
if (r.ok) set({ branches: r.data })
|
|
92
|
+
},
|
|
93
|
+
}))
|
package/src/utils/api.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ApiResult } from '../../shared/wtui-types'
|
|
2
|
+
|
|
3
|
+
const apiBase = import.meta.env.VITE_WTUI_API_URL as string | undefined
|
|
4
|
+
|
|
5
|
+
function withBase(path: string) {
|
|
6
|
+
if (!apiBase) return path
|
|
7
|
+
return new URL(path, apiBase).toString()
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function apiGet<T>(path: string): Promise<ApiResult<T>> {
|
|
11
|
+
const res = await fetch(withBase(path))
|
|
12
|
+
return (await res.json()) as ApiResult<T>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function apiPost<T, B>(path: string, body: B): Promise<ApiResult<T>> {
|
|
16
|
+
const res = await fetch(withBase(path), {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: { 'Content-Type': 'application/json' },
|
|
19
|
+
body: JSON.stringify(body),
|
|
20
|
+
})
|
|
21
|
+
return (await res.json()) as ApiResult<T>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function apiPut<T, B>(path: string, body: B): Promise<ApiResult<T>> {
|
|
25
|
+
const res = await fetch(withBase(path), {
|
|
26
|
+
method: 'PUT',
|
|
27
|
+
headers: { 'Content-Type': 'application/json' },
|
|
28
|
+
body: JSON.stringify(body),
|
|
29
|
+
})
|
|
30
|
+
return (await res.json()) as ApiResult<T>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function apiDelete<T>(path: string): Promise<ApiResult<T>> {
|
|
34
|
+
const res = await fetch(withBase(path), { method: 'DELETE' })
|
|
35
|
+
return (await res.json()) as ApiResult<T>
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
import react from '@vitejs/plugin-react'
|
|
3
|
+
import tsconfigPaths from "vite-tsconfig-paths";
|
|
4
|
+
import { traeBadgePlugin } from 'vite-plugin-trae-solo-badge';
|
|
5
|
+
import { createRequire } from 'node:module'
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url)
|
|
8
|
+
const hasDevLocator = (() => {
|
|
9
|
+
try {
|
|
10
|
+
require.resolve('babel-plugin-react-dev-locator')
|
|
11
|
+
return true
|
|
12
|
+
} catch {
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
})()
|
|
16
|
+
|
|
17
|
+
// https://vite.dev/config/
|
|
18
|
+
export default defineConfig({
|
|
19
|
+
plugins: [
|
|
20
|
+
react({
|
|
21
|
+
babel: {
|
|
22
|
+
plugins: hasDevLocator ? ['react-dev-locator'] : [],
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
traeBadgePlugin({
|
|
26
|
+
variant: 'dark',
|
|
27
|
+
position: 'bottom-right',
|
|
28
|
+
prodOnly: true,
|
|
29
|
+
clickable: true,
|
|
30
|
+
clickUrl: 'https://www.trae.ai/solo?showJoin=1',
|
|
31
|
+
autoTheme: true,
|
|
32
|
+
autoThemeTarget: '#root'
|
|
33
|
+
}),
|
|
34
|
+
tsconfigPaths(),
|
|
35
|
+
],
|
|
36
|
+
server: {
|
|
37
|
+
proxy: {
|
|
38
|
+
'/api': {
|
|
39
|
+
target: process.env.WTUI_API_URL || 'http://localhost:3001',
|
|
40
|
+
changeOrigin: true,
|
|
41
|
+
secure: false,
|
|
42
|
+
localAddress: '127.0.0.1',
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
})
|