@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,21 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'wtree');
5
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
6
+ export function readConfig() {
7
+ try {
8
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
9
+ return JSON.parse(raw);
10
+ }
11
+ catch {
12
+ return {};
13
+ }
14
+ }
15
+ export function writeConfig(next) {
16
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
17
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2), 'utf-8');
18
+ }
19
+ export function getConfigPaths() {
20
+ return { dir: CONFIG_DIR, path: CONFIG_PATH };
21
+ }
@@ -0,0 +1,24 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ export function exec(command, args, options = {}) {
3
+ const r = spawnSync(command, args, {
4
+ cwd: options.cwd,
5
+ encoding: 'utf-8',
6
+ });
7
+ const stdout = (r.stdout || '').toString();
8
+ const stderr = (r.stderr || '').toString();
9
+ const ok = r.status === 0;
10
+ return {
11
+ ok,
12
+ stdout: stdout.trimEnd(),
13
+ stderr: stderr.trimEnd(),
14
+ exitCode: r.status,
15
+ };
16
+ }
17
+ export function execOrThrow(command, args, options = {}) {
18
+ const r = exec(command, args, { cwd: options.cwd });
19
+ if (r.ok)
20
+ return r;
21
+ const error = new Error(`${options.errorCode ? `[${options.errorCode}] ` : ''}${command} ${args.join(' ')}\n${r.stderr || r.stdout}`);
22
+ error.exec = { command, args, ...r };
23
+ throw error;
24
+ }
@@ -0,0 +1,24 @@
1
+ import path from 'node:path';
2
+ import { exec, execOrThrow } from './exec.js';
3
+ const withSafeArgs = (args) => ['-c', 'safe.directory=*', ...args];
4
+ export function getRepoRoot(cwd) {
5
+ const common = exec('git', withSafeArgs(['rev-parse', '--path-format=absolute', '--git-common-dir']), { cwd });
6
+ if (common.ok && common.stdout.trim()) {
7
+ return path.dirname(common.stdout.trim());
8
+ }
9
+ const top = exec('git', withSafeArgs(['rev-parse', '--show-toplevel']), { cwd });
10
+ if (top.ok && top.stdout.trim()) {
11
+ return top.stdout.trim();
12
+ }
13
+ throw new Error('无法确定 Git 根目录。请确保在 Git 仓库中运行。');
14
+ }
15
+ export function getGitDirAbsolute(rootDir) {
16
+ const r = execOrThrow('git', withSafeArgs(['rev-parse', '--path-format=absolute', '--git-dir']), { cwd: rootDir, errorCode: 'GIT_DIR' });
17
+ return r.stdout.trim();
18
+ }
19
+ export function git(rootDir, args) {
20
+ return exec('git', withSafeArgs(args), { cwd: rootDir });
21
+ }
22
+ export function gitOrThrow(rootDir, args, errorCode) {
23
+ return execOrThrow('git', withSafeArgs(args), { cwd: rootDir, errorCode });
24
+ }
@@ -0,0 +1,6 @@
1
+ export function idFromPath(p) {
2
+ return Buffer.from(p, 'utf-8').toString('base64url');
3
+ }
4
+ export function pathFromId(id) {
5
+ return Buffer.from(id, 'base64url').toString('utf-8');
6
+ }
@@ -0,0 +1,51 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import process from 'node:process';
3
+ function hasCommand(cmd) {
4
+ try {
5
+ const checkCmd = process.platform === 'win32' ? 'where' : 'which';
6
+ const r = spawnSync(checkCmd, [cmd], { stdio: 'ignore' });
7
+ return r.status === 0;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ export function openPath(targetPath, openCommand) {
14
+ const platform = process.platform;
15
+ if (openCommand) {
16
+ const r = spawnSync(openCommand, [targetPath], { stdio: 'ignore' });
17
+ return r.status === 0;
18
+ }
19
+ if (platform === 'darwin') {
20
+ const r = spawnSync('open', [targetPath], { stdio: 'ignore' });
21
+ return r.status === 0;
22
+ }
23
+ if (platform === 'win32') {
24
+ const r = spawnSync('cmd', ['/c', 'start', '', targetPath], {
25
+ stdio: 'ignore',
26
+ windowsVerbatimArguments: true,
27
+ });
28
+ return r.status === 0;
29
+ }
30
+ const r = spawnSync('xdg-open', [targetPath], { stdio: 'ignore' });
31
+ return r.status === 0;
32
+ }
33
+ export function openEditor(targetPath, editorCommand) {
34
+ if (editorCommand) {
35
+ // Split command and args? For now assume command is single word or handled by spawnSync if passed as array.
36
+ // Actually spawnSync(cmd, args) expects cmd to be executable.
37
+ // If editorCommand is "code -r", it might fail if passed as cmd.
38
+ // We should probably split by space if user provided args, but let's keep it simple: assume user provides executable name.
39
+ // Or we can use shell: true for custom commands.
40
+ const r = spawnSync(editorCommand, [targetPath], { stdio: 'ignore', shell: true });
41
+ return r.status === 0;
42
+ }
43
+ const editors = ['trae', 'cursor', 'code'];
44
+ for (const ed of editors) {
45
+ if (hasCommand(ed)) {
46
+ const r = spawnSync(ed, [targetPath], { stdio: 'ignore' });
47
+ return r.status === 0;
48
+ }
49
+ }
50
+ return false;
51
+ }
@@ -0,0 +1,58 @@
1
+ import path from 'node:path';
2
+ import { gitOrThrow } from './git.js';
3
+ import { idFromPath } from './id.js';
4
+ export function parseWorktreePorcelain(output) {
5
+ const lines = output.split('\n');
6
+ const items = [];
7
+ let current = {};
8
+ const flush = () => {
9
+ if (!current.path)
10
+ return;
11
+ items.push({
12
+ path: current.path,
13
+ head: current.head || '',
14
+ branch: current.branch,
15
+ isLocked: Boolean(current.isLocked),
16
+ });
17
+ current = {};
18
+ };
19
+ for (const line of lines) {
20
+ if (line.startsWith('worktree ')) {
21
+ flush();
22
+ current.path = line.slice('worktree '.length).trim();
23
+ continue;
24
+ }
25
+ if (line.startsWith('HEAD ')) {
26
+ current.head = line.slice('HEAD '.length).trim();
27
+ continue;
28
+ }
29
+ if (line.startsWith('branch ')) {
30
+ const b = line.slice('branch '.length).trim();
31
+ current.branch = b.replace(/^refs\/heads\//, '');
32
+ continue;
33
+ }
34
+ if (line.startsWith('locked')) {
35
+ current.isLocked = true;
36
+ continue;
37
+ }
38
+ }
39
+ flush();
40
+ return items;
41
+ }
42
+ export function listWorktrees(rootDir) {
43
+ const r = gitOrThrow(rootDir, ['worktree', 'list', '--porcelain'], 'WORKTREE_LIST');
44
+ const raw = parseWorktreePorcelain(r.stdout);
45
+ return raw
46
+ .filter((wt) => wt.path)
47
+ .map((wt) => {
48
+ const normalized = path.resolve(wt.path);
49
+ return {
50
+ id: idFromPath(normalized),
51
+ path: normalized,
52
+ head: wt.head,
53
+ branch: wt.branch,
54
+ isMain: path.resolve(normalized) === path.resolve(rootDir),
55
+ isLocked: wt.isLocked,
56
+ };
57
+ });
58
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseWorktreePorcelain } from './worktree.js';
3
+ describe('parseWorktreePorcelain', () => {
4
+ it('parses multiple worktrees with branch and locked', () => {
5
+ const output = [
6
+ 'worktree /repo',
7
+ 'HEAD 1111111111111111111111111111111111111111',
8
+ 'branch refs/heads/main',
9
+ 'worktree /repo/worktrees/feature-a',
10
+ 'HEAD 2222222222222222222222222222222222222222',
11
+ 'branch refs/heads/feature/a',
12
+ 'locked',
13
+ '',
14
+ ].join('\n');
15
+ const items = parseWorktreePorcelain(output);
16
+ expect(items).toHaveLength(2);
17
+ expect(items[0]).toEqual({
18
+ path: '/repo',
19
+ head: '1111111111111111111111111111111111111111',
20
+ branch: 'main',
21
+ isLocked: false,
22
+ });
23
+ expect(items[1]).toEqual({
24
+ path: '/repo/worktrees/feature-a',
25
+ head: '2222222222222222222222222222222222222222',
26
+ branch: 'feature/a',
27
+ isLocked: true,
28
+ });
29
+ });
30
+ });
@@ -0,0 +1,26 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import { createWorktreeRouter } from './routes/worktrees.js';
4
+ export function createApiApp(getRepoRoot) {
5
+ const app = express();
6
+ app.use(cors());
7
+ app.use(express.json({ limit: '10mb' }));
8
+ app.use(express.urlencoded({ extended: true, limit: '10mb' }));
9
+ app.use('/api', createWorktreeRouter(getRepoRoot));
10
+ app.get('/api/health', (req, res) => {
11
+ res.status(200).json({ ok: true, data: { status: 'ok' } });
12
+ });
13
+ app.use((error, req, res, next) => {
14
+ void error;
15
+ void req;
16
+ void next;
17
+ res.status(500).json({
18
+ ok: false,
19
+ error: { code: 'INTERNAL', message: 'Server internal error' },
20
+ });
21
+ });
22
+ app.use((req, res) => {
23
+ res.status(404).json({ ok: false, error: { code: 'NOT_FOUND', message: 'API not found' } });
24
+ });
25
+ return app;
26
+ }
@@ -0,0 +1,213 @@
1
+ import express from 'express';
2
+ import path from 'node:path';
3
+ import { getGitDirAbsolute } from '../core/git.js';
4
+ import { listWorktrees } from '../core/worktree.js';
5
+ import { gitOrThrow } from '../core/git.js';
6
+ import { pathFromId } from '../core/id.js';
7
+ import { openPath, openEditor } from '../core/open.js';
8
+ import { readConfig, writeConfig } from '../core/config.js';
9
+ export function createWorktreeRouter(getRepoRoot) {
10
+ const router = express.Router();
11
+ const errMsg = (e) => (e instanceof Error ? e.message : String(e));
12
+ router.get('/repo', (req, res) => {
13
+ try {
14
+ const rootPath = getRepoRoot();
15
+ const gitDirPath = getGitDirAbsolute(rootPath);
16
+ res.json({ ok: true, data: { rootPath, gitDirPath } });
17
+ }
18
+ catch (e) {
19
+ res.status(400).json({
20
+ ok: false,
21
+ error: { code: 'REPO_NOT_FOUND', message: errMsg(e) || 'Repo not found' },
22
+ });
23
+ }
24
+ });
25
+ router.get('/worktrees', (req, res) => {
26
+ try {
27
+ const root = getRepoRoot();
28
+ const items = listWorktrees(root);
29
+ res.json({ ok: true, data: items });
30
+ }
31
+ catch (e) {
32
+ res.status(500).json({
33
+ ok: false,
34
+ error: { code: 'WORKTREE_LIST_FAILED', message: errMsg(e) || 'List failed' },
35
+ });
36
+ }
37
+ });
38
+ router.post('/worktrees', (req, res) => {
39
+ try {
40
+ const root = getRepoRoot();
41
+ const body = req.body;
42
+ if (!body || !body.ref || !body.path) {
43
+ res.status(400).json({
44
+ ok: false,
45
+ error: { code: 'INVALID_INPUT', message: 'ref 与 path 不能为空' },
46
+ });
47
+ return;
48
+ }
49
+ const targetDir = path.isAbsolute(body.path)
50
+ ? body.path
51
+ : path.resolve(root, body.path);
52
+ if (body.newBranch && body.newBranch.trim()) {
53
+ gitOrThrow(root, ['branch', body.newBranch.trim(), body.ref.trim()], 'BRANCH_CREATE');
54
+ gitOrThrow(root, ['worktree', 'add', targetDir, body.newBranch.trim()], 'WORKTREE_ADD');
55
+ }
56
+ else {
57
+ gitOrThrow(root, ['worktree', 'add', targetDir, body.ref.trim()], 'WORKTREE_ADD');
58
+ }
59
+ const items = listWorktrees(root);
60
+ const created = items.find((x) => path.resolve(x.path) === path.resolve(targetDir));
61
+ if (!created) {
62
+ res.json({
63
+ ok: false,
64
+ error: { code: 'WORKTREE_CREATE_UNKNOWN', message: 'Worktree 创建成功但未能读取到列表' },
65
+ });
66
+ return;
67
+ }
68
+ res.json({ ok: true, data: created });
69
+ }
70
+ catch (e) {
71
+ res.status(500).json({
72
+ ok: false,
73
+ error: {
74
+ code: 'WORKTREE_CREATE_FAILED',
75
+ message: '创建失败',
76
+ details: errMsg(e),
77
+ },
78
+ });
79
+ }
80
+ });
81
+ router.delete('/worktrees/:id', (req, res) => {
82
+ const force = req.query.force === '1' || req.query.force === 'true';
83
+ try {
84
+ const root = getRepoRoot();
85
+ const p = pathFromId(req.params.id);
86
+ const args = force ? ['worktree', 'remove', '--force', p] : ['worktree', 'remove', p];
87
+ gitOrThrow(root, args, 'WORKTREE_REMOVE');
88
+ res.json({ ok: true, data: { removed: true } });
89
+ }
90
+ catch (e) {
91
+ res.status(500).json({
92
+ ok: false,
93
+ error: {
94
+ code: 'WORKTREE_REMOVE_FAILED',
95
+ message: '删除失败',
96
+ details: errMsg(e),
97
+ },
98
+ });
99
+ }
100
+ });
101
+ router.post('/worktrees/:id/open', (req, res) => {
102
+ try {
103
+ const p = pathFromId(req.params.id);
104
+ const cfg = readConfig();
105
+ const type = req.body.type || 'folder';
106
+ let ok = false;
107
+ if (type === 'editor') {
108
+ ok = openEditor(p, cfg.editorCommand);
109
+ }
110
+ else {
111
+ ok = openPath(p, cfg.openCommand);
112
+ }
113
+ if (!ok) {
114
+ res.status(500).json({
115
+ ok: false,
116
+ error: {
117
+ code: 'OPEN_FAILED',
118
+ message: type === 'editor'
119
+ ? '无法打开编辑器 (未找到 Trae/Cursor/VSCode,请在设置中配置 editorCommand)'
120
+ : '打开失败',
121
+ },
122
+ });
123
+ return;
124
+ }
125
+ res.json({ ok: true, data: { launched: true } });
126
+ }
127
+ catch (e) {
128
+ res.status(500).json({
129
+ ok: false,
130
+ error: { code: 'OPEN_FAILED', message: errMsg(e) || '打开失败' },
131
+ });
132
+ }
133
+ });
134
+ router.post('/worktrees/:id/lock', (req, res) => {
135
+ try {
136
+ const root = getRepoRoot();
137
+ const p = pathFromId(req.params.id);
138
+ gitOrThrow(root, ['worktree', 'lock', p], 'WORKTREE_LOCK');
139
+ res.json({ ok: true, data: { locked: true } });
140
+ }
141
+ catch (e) {
142
+ res.status(500).json({
143
+ ok: false,
144
+ error: { code: 'LOCK_FAILED', message: errMsg(e) || 'Lock failed' },
145
+ });
146
+ }
147
+ });
148
+ router.post('/worktrees/:id/unlock', (req, res) => {
149
+ try {
150
+ const root = getRepoRoot();
151
+ const p = pathFromId(req.params.id);
152
+ gitOrThrow(root, ['worktree', 'unlock', p], 'WORKTREE_UNLOCK');
153
+ res.json({ ok: true, data: { unlocked: true } });
154
+ }
155
+ catch (e) {
156
+ res.status(500).json({
157
+ ok: false,
158
+ error: { code: 'UNLOCK_FAILED', message: errMsg(e) || 'Unlock failed' },
159
+ });
160
+ }
161
+ });
162
+ router.post('/worktrees/prune', (req, res) => {
163
+ try {
164
+ const root = getRepoRoot();
165
+ gitOrThrow(root, ['worktree', 'prune'], 'WORKTREE_PRUNE');
166
+ res.json({ ok: true, data: { pruned: true } });
167
+ }
168
+ catch (e) {
169
+ res.status(500).json({
170
+ ok: false,
171
+ error: { code: 'PRUNE_FAILED', message: errMsg(e) || 'Prune failed' },
172
+ });
173
+ }
174
+ });
175
+ router.get('/branches', (req, res) => {
176
+ try {
177
+ const root = getRepoRoot();
178
+ const local = gitOrThrow(root, ['branch', '--format=%(refname:short)']).stdout
179
+ .split('\n')
180
+ .map((b) => b.trim())
181
+ .filter(Boolean);
182
+ const remote = gitOrThrow(root, ['branch', '-r', '--format=%(refname:short)']).stdout
183
+ .split('\n')
184
+ .map((b) => b.trim())
185
+ .filter((b) => b && !b.includes('/HEAD'));
186
+ const all = Array.from(new Set([...local, ...remote])).sort();
187
+ res.json({ ok: true, data: all });
188
+ }
189
+ catch (e) {
190
+ res.status(500).json({
191
+ ok: false,
192
+ error: { code: 'BRANCH_LIST_FAILED', message: errMsg(e) || 'List branches failed' },
193
+ });
194
+ }
195
+ });
196
+ router.get('/config', (req, res) => {
197
+ res.json({ ok: true, data: readConfig() });
198
+ });
199
+ router.put('/config', (req, res) => {
200
+ try {
201
+ const next = req.body || {};
202
+ writeConfig(next);
203
+ res.json({ ok: true, data: readConfig() });
204
+ }
205
+ catch (e) {
206
+ res.status(500).json({
207
+ ok: false,
208
+ error: { code: 'CONFIG_WRITE_FAILED', message: '保存失败', details: errMsg(e) },
209
+ });
210
+ }
211
+ });
212
+ return router;
213
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * local server entry file, for local development
3
+ */
4
+ import app from './app.js';
5
+ /**
6
+ * start server with port
7
+ */
8
+ const PORT = process.env.PORT || 3001;
9
+ const server = app.listen(PORT, () => {
10
+ console.log(`Server ready on port ${PORT}`);
11
+ });
12
+ /**
13
+ * close server
14
+ */
15
+ process.on('SIGTERM', () => {
16
+ console.log('SIGTERM signal received');
17
+ server.close(() => {
18
+ console.log('Server closed');
19
+ process.exit(0);
20
+ });
21
+ });
22
+ process.on('SIGINT', () => {
23
+ console.log('SIGINT signal received');
24
+ server.close(() => {
25
+ console.log('Server closed');
26
+ process.exit(0);
27
+ });
28
+ });
29
+ export default app;
@@ -0,0 +1,65 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createServer } from 'vite';
6
+ import { createApiApp } from '../createApiApp.js';
7
+ import { openPath } from '../core/open.js';
8
+ function findUiRoot(fromDir) {
9
+ let cur = fromDir;
10
+ for (let i = 0; i < 10; i += 1) {
11
+ const indexHtml = path.join(cur, 'index.html');
12
+ const viteConfig = path.join(cur, 'vite.config.ts');
13
+ if (exists(indexHtml) && exists(viteConfig))
14
+ return cur;
15
+ const parent = path.dirname(cur);
16
+ if (parent === cur)
17
+ break;
18
+ cur = parent;
19
+ }
20
+ return fromDir;
21
+ }
22
+ function exists(p) {
23
+ return fs.existsSync(p);
24
+ }
25
+ export async function startUiDevServer(options) {
26
+ const repoRoot = options.repoRoot;
27
+ const open = options.open !== false;
28
+ const uiPort = Number.isFinite(options.uiPort) ? options.uiPort : 5173;
29
+ const apiApp = createApiApp(() => repoRoot);
30
+ const apiServer = apiApp.listen(0, '127.0.0.1');
31
+ await new Promise((resolve) => apiServer.once('listening', () => resolve()));
32
+ const apiAddress = apiServer.address();
33
+ const apiPort = typeof apiAddress === 'object' && apiAddress ? apiAddress.port : 0;
34
+ process.env.WTUI_API_URL = `http://127.0.0.1:${apiPort}`;
35
+ process.env.VITE_WTUI_API_URL = process.env.WTUI_API_URL;
36
+ const here = path.dirname(fileURLToPath(import.meta.url));
37
+ const uiRoot = findUiRoot(path.resolve(here, '..', '..'));
38
+ const prevCwd = process.cwd();
39
+ process.chdir(uiRoot);
40
+ const vite = await createServer({
41
+ root: uiRoot,
42
+ configFile: path.join(uiRoot, 'vite.config.ts'),
43
+ cacheDir: path.join(os.tmpdir(), 'wtui-vite-cache'),
44
+ server: {
45
+ host: '127.0.0.1',
46
+ port: uiPort,
47
+ strictPort: false,
48
+ },
49
+ clearScreen: false,
50
+ appType: 'spa',
51
+ });
52
+ await vite.listen();
53
+ const url = vite.resolvedUrls?.local?.[0] || `http://127.0.0.1:${uiPort}/`;
54
+ if (open) {
55
+ openPath(url);
56
+ }
57
+ return {
58
+ uiUrl: url,
59
+ close: async () => {
60
+ await vite.close();
61
+ await new Promise((resolve) => apiServer.close(() => resolve()));
62
+ process.chdir(prevCwd);
63
+ },
64
+ };
65
+ }
@@ -0,0 +1 @@
1
+ export {};
package/index.html ADDED
@@ -0,0 +1,24 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>TreeLab - Git Worktree Manager</title>
8
+ <script type="module">
9
+ if (import.meta.hot?.on) {
10
+ import.meta.hot.on('vite:error', (error) => {
11
+ if (error.err) {
12
+ console.error(
13
+ [error.err.message, error.err.frame].filter(Boolean).join('\n'),
14
+ )
15
+ }
16
+ })
17
+ }
18
+ </script>
19
+ </head>
20
+ <body>
21
+ <div id="root"></div>
22
+ <script type="module" src="/src/main.tsx"></script>
23
+ </body>
24
+ </html>
package/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "name": "@fatdoge/wtree",
3
+ "private": false,
4
+ "version": "0.1.0",
5
+ "description": "CLI + UI tool for managing git worktrees",
6
+ "keywords": [
7
+ "git",
8
+ "worktree",
9
+ "cli",
10
+ "ui",
11
+ "manager"
12
+ ],
13
+ "author": "fatdoge",
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "bin": {
17
+ "wtree": "dist-node/api/cli/wtree.js"
18
+ },
19
+ "files": [
20
+ "dist-node",
21
+ "dist",
22
+ "api",
23
+ "src",
24
+ "shared",
25
+ "index.html",
26
+ "vite.config.ts",
27
+ "tailwind.config.js",
28
+ "postcss.config.js",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "scripts": {
33
+ "client:dev": "vite",
34
+ "build:ui": "vite build",
35
+ "build:cli": "tsc -p tsconfig.node.json",
36
+ "build": "pnpm run build:cli && pnpm run build:ui",
37
+ "lint": "eslint .",
38
+ "preview": "vite preview",
39
+ "check": "tsc --noEmit && tsc -p tsconfig.node.json --noEmit",
40
+ "server:dev": "nodemon",
41
+ "dev": "concurrently \"pnpm run client:dev\" \"pnpm run server:dev\"",
42
+ "wtree": "tsx api/cli/wtree.ts",
43
+ "test": "vitest run"
44
+ },
45
+ "dependencies": {
46
+ "@vitejs/plugin-react": "^4.4.1",
47
+ "autoprefixer": "^10.4.21",
48
+ "chalk": "^5.6.0",
49
+ "clsx": "^2.1.1",
50
+ "cors": "^2.8.5",
51
+ "dotenv": "^17.2.1",
52
+ "express": "^4.21.2",
53
+ "i18next": "^25.8.17",
54
+ "i18next-browser-languagedetector": "^8.2.1",
55
+ "inquirer": "^12.9.4",
56
+ "lucide-react": "^0.511.0",
57
+ "postcss": "^8.5.3",
58
+ "react": "^18.3.1",
59
+ "react-dom": "^18.3.1",
60
+ "react-i18next": "^16.5.7",
61
+ "react-router-dom": "^7.3.0",
62
+ "tailwind-merge": "^3.0.2",
63
+ "tailwindcss": "^3.4.17",
64
+ "vite": "^6.3.5",
65
+ "vite-plugin-trae-solo-badge": "^1.0.0",
66
+ "vite-tsconfig-paths": "^5.1.4",
67
+ "zustand": "^5.0.3"
68
+ },
69
+ "devDependencies": {
70
+ "@eslint/js": "^9.25.0",
71
+ "@types/cors": "^2.8.19",
72
+ "@types/express": "^4.17.21",
73
+ "@types/node": "^22.15.30",
74
+ "@types/react": "^18.3.12",
75
+ "@types/react-dom": "^18.3.1",
76
+ "@vercel/node": "^5.3.6",
77
+ "babel-plugin-react-dev-locator": "^1.0.0",
78
+ "concurrently": "^9.2.0",
79
+ "eslint": "^9.25.0",
80
+ "eslint-plugin-react-hooks": "^5.2.0",
81
+ "eslint-plugin-react-refresh": "^0.4.19",
82
+ "globals": "^16.0.0",
83
+ "nodemon": "^3.1.10",
84
+ "tsx": "^4.20.3",
85
+ "typescript": "~5.8.3",
86
+ "typescript-eslint": "^8.30.1",
87
+ "vitest": "^2.1.9"
88
+ }
89
+ }