@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.
Files changed (65) hide show
  1. package/README.en.md +113 -0
  2. package/README.md +136 -0
  3. package/api/app.ts +19 -0
  4. package/api/cli/wtree.ts +809 -0
  5. package/api/core/config.ts +26 -0
  6. package/api/core/exec.ts +55 -0
  7. package/api/core/git.ts +35 -0
  8. package/api/core/id.ts +8 -0
  9. package/api/core/open.ts +58 -0
  10. package/api/core/worktree.test.ts +33 -0
  11. package/api/core/worktree.ts +72 -0
  12. package/api/createApiApp.ts +33 -0
  13. package/api/index.ts +9 -0
  14. package/api/routes/worktrees.ts +255 -0
  15. package/api/server.ts +34 -0
  16. package/api/ui/startUiDev.ts +82 -0
  17. package/dist/assets/index-D9inyPb3.js +179 -0
  18. package/dist/assets/index-W34LSHWF.css +1 -0
  19. package/dist/favicon.svg +4 -0
  20. package/dist/index.html +354 -0
  21. package/dist-node/api/app.js +17 -0
  22. package/dist-node/api/cli/wtree.js +722 -0
  23. package/dist-node/api/cli/wtui.js +722 -0
  24. package/dist-node/api/core/config.js +21 -0
  25. package/dist-node/api/core/exec.js +24 -0
  26. package/dist-node/api/core/git.js +24 -0
  27. package/dist-node/api/core/id.js +6 -0
  28. package/dist-node/api/core/open.js +51 -0
  29. package/dist-node/api/core/worktree.js +58 -0
  30. package/dist-node/api/core/worktree.test.js +30 -0
  31. package/dist-node/api/createApiApp.js +26 -0
  32. package/dist-node/api/routes/worktrees.js +213 -0
  33. package/dist-node/api/server.js +29 -0
  34. package/dist-node/api/ui/startUiDev.js +65 -0
  35. package/dist-node/shared/wtui-types.js +1 -0
  36. package/index.html +24 -0
  37. package/package.json +89 -0
  38. package/postcss.config.js +10 -0
  39. package/shared/wtui-types.ts +36 -0
  40. package/src/App.tsx +28 -0
  41. package/src/assets/react.svg +1 -0
  42. package/src/components/Button.tsx +34 -0
  43. package/src/components/Empty.tsx +8 -0
  44. package/src/components/Input.tsx +16 -0
  45. package/src/components/Modal.tsx +33 -0
  46. package/src/components/ToastHost.tsx +42 -0
  47. package/src/hooks/useTheme.ts +29 -0
  48. package/src/i18n/index.ts +22 -0
  49. package/src/i18n/locales/en.json +145 -0
  50. package/src/i18n/locales/zh.json +145 -0
  51. package/src/index.css +24 -0
  52. package/src/lib/utils.ts +6 -0
  53. package/src/main.tsx +11 -0
  54. package/src/pages/CreateWorktree.tsx +181 -0
  55. package/src/pages/HelpPage.tsx +67 -0
  56. package/src/pages/Home.tsx +3 -0
  57. package/src/pages/SettingsPage.tsx +218 -0
  58. package/src/pages/Worktrees.tsx +354 -0
  59. package/src/stores/themeStore.ts +44 -0
  60. package/src/stores/toastStore.ts +29 -0
  61. package/src/stores/worktreeStore.ts +93 -0
  62. package/src/utils/api.ts +36 -0
  63. package/src/vite-env.d.ts +1 -0
  64. package/tailwind.config.js +13 -0
  65. 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
+ }))
@@ -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" />
@@ -0,0 +1,13 @@
1
+ /** @type {import('tailwindcss').Config} */
2
+
3
+ export default {
4
+ darkMode: 'class',
5
+ content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
6
+ theme: {
7
+ container: {
8
+ center: true,
9
+ },
10
+ extend: {},
11
+ },
12
+ plugins: [],
13
+ };
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
+ })