@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,26 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import type { WtuiConfig } from '../../shared/wtui-types.js'
|
|
6
|
+
|
|
7
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'wtree')
|
|
8
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json')
|
|
9
|
+
|
|
10
|
+
export function readConfig(): WtuiConfig {
|
|
11
|
+
try {
|
|
12
|
+
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8')
|
|
13
|
+
return JSON.parse(raw) as WtuiConfig
|
|
14
|
+
} catch {
|
|
15
|
+
return {}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function writeConfig(next: WtuiConfig) {
|
|
20
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true })
|
|
21
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2), 'utf-8')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getConfigPaths() {
|
|
25
|
+
return { dir: CONFIG_DIR, path: CONFIG_PATH }
|
|
26
|
+
}
|
package/api/core/exec.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
export type ExecResult = {
|
|
4
|
+
ok: boolean
|
|
5
|
+
stdout: string
|
|
6
|
+
stderr: string
|
|
7
|
+
exitCode: number | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ExecError = Error & {
|
|
11
|
+
exec?: {
|
|
12
|
+
command: string
|
|
13
|
+
args: string[]
|
|
14
|
+
ok: boolean
|
|
15
|
+
stdout: string
|
|
16
|
+
stderr: string
|
|
17
|
+
exitCode: number | null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function exec(
|
|
22
|
+
command: string,
|
|
23
|
+
args: string[],
|
|
24
|
+
options: { cwd?: string } = {},
|
|
25
|
+
): ExecResult {
|
|
26
|
+
const r = spawnSync(command, args, {
|
|
27
|
+
cwd: options.cwd,
|
|
28
|
+
encoding: 'utf-8',
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const stdout = (r.stdout || '').toString()
|
|
32
|
+
const stderr = (r.stderr || '').toString()
|
|
33
|
+
const ok = r.status === 0
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
ok,
|
|
37
|
+
stdout: stdout.trimEnd(),
|
|
38
|
+
stderr: stderr.trimEnd(),
|
|
39
|
+
exitCode: r.status,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function execOrThrow(
|
|
44
|
+
command: string,
|
|
45
|
+
args: string[],
|
|
46
|
+
options: { cwd?: string; errorCode?: string } = {},
|
|
47
|
+
) {
|
|
48
|
+
const r = exec(command, args, { cwd: options.cwd })
|
|
49
|
+
if (r.ok) return r
|
|
50
|
+
const error: ExecError = new Error(
|
|
51
|
+
`${options.errorCode ? `[${options.errorCode}] ` : ''}${command} ${args.join(' ')}\n${r.stderr || r.stdout}`,
|
|
52
|
+
)
|
|
53
|
+
error.exec = { command, args, ...r }
|
|
54
|
+
throw error
|
|
55
|
+
}
|
package/api/core/git.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { exec, execOrThrow } from './exec.js'
|
|
3
|
+
|
|
4
|
+
const withSafeArgs = (args: string[]) => ['-c', 'safe.directory=*', ...args]
|
|
5
|
+
|
|
6
|
+
export function getRepoRoot(cwd: string) {
|
|
7
|
+
const common = exec('git', withSafeArgs(['rev-parse', '--path-format=absolute', '--git-common-dir']), { cwd })
|
|
8
|
+
if (common.ok && common.stdout.trim()) {
|
|
9
|
+
return path.dirname(common.stdout.trim())
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const top = exec('git', withSafeArgs(['rev-parse', '--show-toplevel']), { cwd })
|
|
13
|
+
if (top.ok && top.stdout.trim()) {
|
|
14
|
+
return top.stdout.trim()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
throw new Error('无法确定 Git 根目录。请确保在 Git 仓库中运行。')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getGitDirAbsolute(rootDir: string) {
|
|
21
|
+
const r = execOrThrow(
|
|
22
|
+
'git',
|
|
23
|
+
withSafeArgs(['rev-parse', '--path-format=absolute', '--git-dir']),
|
|
24
|
+
{ cwd: rootDir, errorCode: 'GIT_DIR' },
|
|
25
|
+
)
|
|
26
|
+
return r.stdout.trim()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function git(rootDir: string, args: string[]) {
|
|
30
|
+
return exec('git', withSafeArgs(args), { cwd: rootDir })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function gitOrThrow(rootDir: string, args: string[], errorCode?: string) {
|
|
34
|
+
return execOrThrow('git', withSafeArgs(args), { cwd: rootDir, errorCode })
|
|
35
|
+
}
|
package/api/core/id.ts
ADDED
package/api/core/open.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import process from 'node:process'
|
|
3
|
+
|
|
4
|
+
function hasCommand(cmd: string) {
|
|
5
|
+
try {
|
|
6
|
+
const checkCmd = process.platform === 'win32' ? 'where' : 'which'
|
|
7
|
+
const r = spawnSync(checkCmd, [cmd], { stdio: 'ignore' })
|
|
8
|
+
return r.status === 0
|
|
9
|
+
} catch {
|
|
10
|
+
return false
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function openPath(targetPath: string, openCommand?: string) {
|
|
15
|
+
const platform = process.platform
|
|
16
|
+
if (openCommand) {
|
|
17
|
+
const r = spawnSync(openCommand, [targetPath], { stdio: 'ignore' })
|
|
18
|
+
return r.status === 0
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (platform === 'darwin') {
|
|
22
|
+
const r = spawnSync('open', [targetPath], { stdio: 'ignore' })
|
|
23
|
+
return r.status === 0
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (platform === 'win32') {
|
|
27
|
+
const r = spawnSync('cmd', ['/c', 'start', '', targetPath], {
|
|
28
|
+
stdio: 'ignore',
|
|
29
|
+
windowsVerbatimArguments: true,
|
|
30
|
+
})
|
|
31
|
+
return r.status === 0
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const r = spawnSync('xdg-open', [targetPath], { stdio: 'ignore' })
|
|
35
|
+
return r.status === 0
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function openEditor(targetPath: string, editorCommand?: string) {
|
|
39
|
+
if (editorCommand) {
|
|
40
|
+
// Split command and args? For now assume command is single word or handled by spawnSync if passed as array.
|
|
41
|
+
// Actually spawnSync(cmd, args) expects cmd to be executable.
|
|
42
|
+
// If editorCommand is "code -r", it might fail if passed as cmd.
|
|
43
|
+
// We should probably split by space if user provided args, but let's keep it simple: assume user provides executable name.
|
|
44
|
+
// Or we can use shell: true for custom commands.
|
|
45
|
+
const r = spawnSync(editorCommand, [targetPath], { stdio: 'ignore', shell: true })
|
|
46
|
+
return r.status === 0
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const editors = ['trae', 'cursor', 'code']
|
|
50
|
+
for (const ed of editors) {
|
|
51
|
+
if (hasCommand(ed)) {
|
|
52
|
+
const r = spawnSync(ed, [targetPath], { stdio: 'ignore' })
|
|
53
|
+
return r.status === 0
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { parseWorktreePorcelain } from './worktree.js'
|
|
3
|
+
|
|
4
|
+
describe('parseWorktreePorcelain', () => {
|
|
5
|
+
it('parses multiple worktrees with branch and locked', () => {
|
|
6
|
+
const output = [
|
|
7
|
+
'worktree /repo',
|
|
8
|
+
'HEAD 1111111111111111111111111111111111111111',
|
|
9
|
+
'branch refs/heads/main',
|
|
10
|
+
'worktree /repo/worktrees/feature-a',
|
|
11
|
+
'HEAD 2222222222222222222222222222222222222222',
|
|
12
|
+
'branch refs/heads/feature/a',
|
|
13
|
+
'locked',
|
|
14
|
+
'',
|
|
15
|
+
].join('\n')
|
|
16
|
+
|
|
17
|
+
const items = parseWorktreePorcelain(output)
|
|
18
|
+
expect(items).toHaveLength(2)
|
|
19
|
+
expect(items[0]).toEqual({
|
|
20
|
+
path: '/repo',
|
|
21
|
+
head: '1111111111111111111111111111111111111111',
|
|
22
|
+
branch: 'main',
|
|
23
|
+
isLocked: false,
|
|
24
|
+
})
|
|
25
|
+
expect(items[1]).toEqual({
|
|
26
|
+
path: '/repo/worktrees/feature-a',
|
|
27
|
+
head: '2222222222222222222222222222222222222222',
|
|
28
|
+
branch: 'feature/a',
|
|
29
|
+
isLocked: true,
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { gitOrThrow } from './git.js'
|
|
3
|
+
import { idFromPath } from './id.js'
|
|
4
|
+
import type { WorktreeItem } from '../../shared/wtui-types.js'
|
|
5
|
+
|
|
6
|
+
export type WorktreeRaw = {
|
|
7
|
+
path: string
|
|
8
|
+
head: string
|
|
9
|
+
branch?: string
|
|
10
|
+
isLocked: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseWorktreePorcelain(output: string): WorktreeRaw[] {
|
|
14
|
+
const lines = output.split('\n')
|
|
15
|
+
const items: WorktreeRaw[] = []
|
|
16
|
+
let current: Partial<WorktreeRaw> = {}
|
|
17
|
+
|
|
18
|
+
const flush = () => {
|
|
19
|
+
if (!current.path) return
|
|
20
|
+
items.push({
|
|
21
|
+
path: current.path,
|
|
22
|
+
head: current.head || '',
|
|
23
|
+
branch: current.branch,
|
|
24
|
+
isLocked: Boolean(current.isLocked),
|
|
25
|
+
})
|
|
26
|
+
current = {}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
if (line.startsWith('worktree ')) {
|
|
31
|
+
flush()
|
|
32
|
+
current.path = line.slice('worktree '.length).trim()
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
if (line.startsWith('HEAD ')) {
|
|
36
|
+
current.head = line.slice('HEAD '.length).trim()
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
if (line.startsWith('branch ')) {
|
|
40
|
+
const b = line.slice('branch '.length).trim()
|
|
41
|
+
current.branch = b.replace(/^refs\/heads\//, '')
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
if (line.startsWith('locked')) {
|
|
45
|
+
current.isLocked = true
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
flush()
|
|
50
|
+
|
|
51
|
+
return items
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function listWorktrees(rootDir: string): WorktreeItem[] {
|
|
55
|
+
const r = gitOrThrow(rootDir, ['worktree', 'list', '--porcelain'], 'WORKTREE_LIST')
|
|
56
|
+
const raw = parseWorktreePorcelain(r.stdout)
|
|
57
|
+
|
|
58
|
+
return raw
|
|
59
|
+
.filter((wt) => wt.path)
|
|
60
|
+
.map((wt) => {
|
|
61
|
+
const normalized = path.resolve(wt.path)
|
|
62
|
+
return {
|
|
63
|
+
id: idFromPath(normalized),
|
|
64
|
+
path: normalized,
|
|
65
|
+
head: wt.head,
|
|
66
|
+
branch: wt.branch,
|
|
67
|
+
isMain: path.resolve(normalized) === path.resolve(rootDir),
|
|
68
|
+
isLocked: wt.isLocked,
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import express, { type Request, type Response, type NextFunction } from 'express'
|
|
2
|
+
import cors from 'cors'
|
|
3
|
+
import { createWorktreeRouter } from './routes/worktrees.js'
|
|
4
|
+
|
|
5
|
+
export function createApiApp(getRepoRoot: () => string) {
|
|
6
|
+
const app: express.Application = express()
|
|
7
|
+
|
|
8
|
+
app.use(cors())
|
|
9
|
+
app.use(express.json({ limit: '10mb' }))
|
|
10
|
+
app.use(express.urlencoded({ extended: true, limit: '10mb' }))
|
|
11
|
+
|
|
12
|
+
app.use('/api', createWorktreeRouter(getRepoRoot))
|
|
13
|
+
|
|
14
|
+
app.get('/api/health', (req: Request, res: Response) => {
|
|
15
|
+
res.status(200).json({ ok: true, data: { status: 'ok' } })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
app.use((error: Error, req: Request, res: Response, next: NextFunction) => {
|
|
19
|
+
void error
|
|
20
|
+
void req
|
|
21
|
+
void next
|
|
22
|
+
res.status(500).json({
|
|
23
|
+
ok: false,
|
|
24
|
+
error: { code: 'INTERNAL', message: 'Server internal error' },
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
app.use((req: Request, res: Response) => {
|
|
29
|
+
res.status(404).json({ ok: false, error: { code: 'NOT_FOUND', message: 'API not found' } })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
return app
|
|
33
|
+
}
|
package/api/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vercel deploy entry handler, for serverless deployment, please don't modify this file
|
|
3
|
+
*/
|
|
4
|
+
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
|
5
|
+
import app from './app.js';
|
|
6
|
+
|
|
7
|
+
export default function handler(req: VercelRequest, res: VercelResponse) {
|
|
8
|
+
return app(req, res);
|
|
9
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import express, { type Request, type Response } 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
|
+
import type {
|
|
10
|
+
ApiResult,
|
|
11
|
+
CreateWorktreeRequest,
|
|
12
|
+
RepoInfo,
|
|
13
|
+
WtuiConfig,
|
|
14
|
+
WorktreeItem,
|
|
15
|
+
} from '../../shared/wtui-types.js'
|
|
16
|
+
|
|
17
|
+
export function createWorktreeRouter(getRepoRoot: () => string) {
|
|
18
|
+
const router = express.Router()
|
|
19
|
+
|
|
20
|
+
const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e))
|
|
21
|
+
|
|
22
|
+
router.get('/repo', (req: Request, res: Response<ApiResult<RepoInfo>>) => {
|
|
23
|
+
try {
|
|
24
|
+
const rootPath = getRepoRoot()
|
|
25
|
+
const gitDirPath = getGitDirAbsolute(rootPath)
|
|
26
|
+
res.json({ ok: true, data: { rootPath, gitDirPath } })
|
|
27
|
+
} catch (e: unknown) {
|
|
28
|
+
res.status(400).json({
|
|
29
|
+
ok: false,
|
|
30
|
+
error: { code: 'REPO_NOT_FOUND', message: errMsg(e) || 'Repo not found' },
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
router.get(
|
|
36
|
+
'/worktrees',
|
|
37
|
+
(req: Request, res: Response<ApiResult<WorktreeItem[]>>) => {
|
|
38
|
+
try {
|
|
39
|
+
const root = getRepoRoot()
|
|
40
|
+
const items = listWorktrees(root)
|
|
41
|
+
res.json({ ok: true, data: items })
|
|
42
|
+
} catch (e: unknown) {
|
|
43
|
+
res.status(500).json({
|
|
44
|
+
ok: false,
|
|
45
|
+
error: { code: 'WORKTREE_LIST_FAILED', message: errMsg(e) || 'List failed' },
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
router.post(
|
|
52
|
+
'/worktrees',
|
|
53
|
+
(
|
|
54
|
+
req: Request<Record<string, never>, ApiResult<WorktreeItem>, CreateWorktreeRequest>,
|
|
55
|
+
res: Response<ApiResult<WorktreeItem>>,
|
|
56
|
+
) => {
|
|
57
|
+
try {
|
|
58
|
+
const root = getRepoRoot()
|
|
59
|
+
const body = req.body
|
|
60
|
+
|
|
61
|
+
if (!body || !body.ref || !body.path) {
|
|
62
|
+
res.status(400).json({
|
|
63
|
+
ok: false,
|
|
64
|
+
error: { code: 'INVALID_INPUT', message: 'ref 与 path 不能为空' },
|
|
65
|
+
})
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const targetDir = path.isAbsolute(body.path)
|
|
70
|
+
? body.path
|
|
71
|
+
: path.resolve(root, body.path)
|
|
72
|
+
|
|
73
|
+
if (body.newBranch && body.newBranch.trim()) {
|
|
74
|
+
gitOrThrow(root, ['branch', body.newBranch.trim(), body.ref.trim()], 'BRANCH_CREATE')
|
|
75
|
+
gitOrThrow(root, ['worktree', 'add', targetDir, body.newBranch.trim()], 'WORKTREE_ADD')
|
|
76
|
+
} else {
|
|
77
|
+
gitOrThrow(root, ['worktree', 'add', targetDir, body.ref.trim()], 'WORKTREE_ADD')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const items = listWorktrees(root)
|
|
81
|
+
const created = items.find((x) => path.resolve(x.path) === path.resolve(targetDir))
|
|
82
|
+
if (!created) {
|
|
83
|
+
res.json({
|
|
84
|
+
ok: false,
|
|
85
|
+
error: { code: 'WORKTREE_CREATE_UNKNOWN', message: 'Worktree 创建成功但未能读取到列表' },
|
|
86
|
+
})
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
res.json({ ok: true, data: created })
|
|
90
|
+
} catch (e: unknown) {
|
|
91
|
+
res.status(500).json({
|
|
92
|
+
ok: false,
|
|
93
|
+
error: {
|
|
94
|
+
code: 'WORKTREE_CREATE_FAILED',
|
|
95
|
+
message: '创建失败',
|
|
96
|
+
details: errMsg(e),
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
router.delete(
|
|
104
|
+
'/worktrees/:id',
|
|
105
|
+
(req: Request<{ id: string }>, res: Response<ApiResult<{ removed: true }>>) => {
|
|
106
|
+
const force = req.query.force === '1' || req.query.force === 'true'
|
|
107
|
+
try {
|
|
108
|
+
const root = getRepoRoot()
|
|
109
|
+
const p = pathFromId(req.params.id)
|
|
110
|
+
const args = force ? ['worktree', 'remove', '--force', p] : ['worktree', 'remove', p]
|
|
111
|
+
gitOrThrow(root, args, 'WORKTREE_REMOVE')
|
|
112
|
+
res.json({ ok: true, data: { removed: true } })
|
|
113
|
+
} catch (e: unknown) {
|
|
114
|
+
res.status(500).json({
|
|
115
|
+
ok: false,
|
|
116
|
+
error: {
|
|
117
|
+
code: 'WORKTREE_REMOVE_FAILED',
|
|
118
|
+
message: '删除失败',
|
|
119
|
+
details: errMsg(e),
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
router.post(
|
|
127
|
+
'/worktrees/:id/open',
|
|
128
|
+
(req: Request<{ id: string }, ApiResult<{ launched: true }>, { type?: 'folder' | 'editor' }>, res: Response<ApiResult<{ launched: true }>>) => {
|
|
129
|
+
try {
|
|
130
|
+
const p = pathFromId(req.params.id)
|
|
131
|
+
const cfg = readConfig()
|
|
132
|
+
const type = req.body.type || 'folder'
|
|
133
|
+
|
|
134
|
+
let ok = false
|
|
135
|
+
if (type === 'editor') {
|
|
136
|
+
ok = openEditor(p, cfg.editorCommand)
|
|
137
|
+
} else {
|
|
138
|
+
ok = openPath(p, cfg.openCommand)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!ok) {
|
|
142
|
+
res.status(500).json({
|
|
143
|
+
ok: false,
|
|
144
|
+
error: {
|
|
145
|
+
code: 'OPEN_FAILED',
|
|
146
|
+
message:
|
|
147
|
+
type === 'editor'
|
|
148
|
+
? '无法打开编辑器 (未找到 Trae/Cursor/VSCode,请在设置中配置 editorCommand)'
|
|
149
|
+
: '打开失败',
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
res.json({ ok: true, data: { launched: true } })
|
|
155
|
+
} catch (e: unknown) {
|
|
156
|
+
res.status(500).json({
|
|
157
|
+
ok: false,
|
|
158
|
+
error: { code: 'OPEN_FAILED', message: errMsg(e) || '打开失败' },
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
router.post(
|
|
165
|
+
'/worktrees/:id/lock',
|
|
166
|
+
(req: Request<{ id: string }>, res: Response<ApiResult<{ locked: true }>>) => {
|
|
167
|
+
try {
|
|
168
|
+
const root = getRepoRoot()
|
|
169
|
+
const p = pathFromId(req.params.id)
|
|
170
|
+
gitOrThrow(root, ['worktree', 'lock', p], 'WORKTREE_LOCK')
|
|
171
|
+
res.json({ ok: true, data: { locked: true } })
|
|
172
|
+
} catch (e: unknown) {
|
|
173
|
+
res.status(500).json({
|
|
174
|
+
ok: false,
|
|
175
|
+
error: { code: 'LOCK_FAILED', message: errMsg(e) || 'Lock failed' },
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
router.post(
|
|
182
|
+
'/worktrees/:id/unlock',
|
|
183
|
+
(req: Request<{ id: string }>, res: Response<ApiResult<{ unlocked: true }>>) => {
|
|
184
|
+
try {
|
|
185
|
+
const root = getRepoRoot()
|
|
186
|
+
const p = pathFromId(req.params.id)
|
|
187
|
+
gitOrThrow(root, ['worktree', 'unlock', p], 'WORKTREE_UNLOCK')
|
|
188
|
+
res.json({ ok: true, data: { unlocked: true } })
|
|
189
|
+
} catch (e: unknown) {
|
|
190
|
+
res.status(500).json({
|
|
191
|
+
ok: false,
|
|
192
|
+
error: { code: 'UNLOCK_FAILED', message: errMsg(e) || 'Unlock failed' },
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
router.post('/worktrees/prune', (req: Request, res: Response<ApiResult<{ pruned: true }>>) => {
|
|
199
|
+
try {
|
|
200
|
+
const root = getRepoRoot()
|
|
201
|
+
gitOrThrow(root, ['worktree', 'prune'], 'WORKTREE_PRUNE')
|
|
202
|
+
res.json({ ok: true, data: { pruned: true } })
|
|
203
|
+
} catch (e: unknown) {
|
|
204
|
+
res.status(500).json({
|
|
205
|
+
ok: false,
|
|
206
|
+
error: { code: 'PRUNE_FAILED', message: errMsg(e) || 'Prune failed' },
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
router.get('/branches', (req: Request, res: Response<ApiResult<string[]>>) => {
|
|
212
|
+
try {
|
|
213
|
+
const root = getRepoRoot()
|
|
214
|
+
const local = gitOrThrow(root, ['branch', '--format=%(refname:short)']).stdout
|
|
215
|
+
.split('\n')
|
|
216
|
+
.map((b) => b.trim())
|
|
217
|
+
.filter(Boolean)
|
|
218
|
+
|
|
219
|
+
const remote = gitOrThrow(root, ['branch', '-r', '--format=%(refname:short)']).stdout
|
|
220
|
+
.split('\n')
|
|
221
|
+
.map((b) => b.trim())
|
|
222
|
+
.filter((b) => b && !b.includes('/HEAD'))
|
|
223
|
+
|
|
224
|
+
const all = Array.from(new Set([...local, ...remote])).sort()
|
|
225
|
+
res.json({ ok: true, data: all })
|
|
226
|
+
} catch (e: unknown) {
|
|
227
|
+
res.status(500).json({
|
|
228
|
+
ok: false,
|
|
229
|
+
error: { code: 'BRANCH_LIST_FAILED', message: errMsg(e) || 'List branches failed' },
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
router.get('/config', (req: Request, res: Response<ApiResult<WtuiConfig>>) => {
|
|
235
|
+
res.json({ ok: true, data: readConfig() })
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
router.put(
|
|
239
|
+
'/config',
|
|
240
|
+
(req: Request<Record<string, never>, ApiResult<WtuiConfig>, WtuiConfig>, res: Response<ApiResult<WtuiConfig>>) => {
|
|
241
|
+
try {
|
|
242
|
+
const next = req.body || {}
|
|
243
|
+
writeConfig(next)
|
|
244
|
+
res.json({ ok: true, data: readConfig() })
|
|
245
|
+
} catch (e: unknown) {
|
|
246
|
+
res.status(500).json({
|
|
247
|
+
ok: false,
|
|
248
|
+
error: { code: 'CONFIG_WRITE_FAILED', message: '保存失败', details: errMsg(e) },
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return router
|
|
255
|
+
}
|
package/api/server.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* local server entry file, for local development
|
|
3
|
+
*/
|
|
4
|
+
import app from './app.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* start server with port
|
|
8
|
+
*/
|
|
9
|
+
const PORT = process.env.PORT || 3001;
|
|
10
|
+
|
|
11
|
+
const server = app.listen(PORT, () => {
|
|
12
|
+
console.log(`Server ready on port ${PORT}`);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* close server
|
|
17
|
+
*/
|
|
18
|
+
process.on('SIGTERM', () => {
|
|
19
|
+
console.log('SIGTERM signal received');
|
|
20
|
+
server.close(() => {
|
|
21
|
+
console.log('Server closed');
|
|
22
|
+
process.exit(0);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
process.on('SIGINT', () => {
|
|
27
|
+
console.log('SIGINT signal received');
|
|
28
|
+
server.close(() => {
|
|
29
|
+
console.log('Server closed');
|
|
30
|
+
process.exit(0);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export default app;
|