code-squad-cli 1.3.0 → 2.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/dist/index.js +439 -2142
- package/dist/tui/App.d.ts +1 -0
- package/dist/tui/App.js +124 -0
- package/package.json +6 -9
- package/dist/dash/InkDashboard.d.ts +0 -13
- package/dist/dash/InkDashboard.js +0 -442
- package/dist/dash/TmuxAdapter.d.ts +0 -233
- package/dist/dash/TmuxAdapter.js +0 -520
- package/dist/dash/index.d.ts +0 -4
- package/dist/dash/index.js +0 -216
- package/dist/dash/pathUtils.d.ts +0 -27
- package/dist/dash/pathUtils.js +0 -70
- package/dist/dash/threadHelpers.d.ts +0 -9
- package/dist/dash/threadHelpers.js +0 -37
- package/dist/dash/types.d.ts +0 -42
- package/dist/dash/types.js +0 -1
- package/dist/dash/useDirectorySuggestions.d.ts +0 -23
- package/dist/dash/useDirectorySuggestions.js +0 -136
- package/dist/dash/usePathValidation.d.ts +0 -9
- package/dist/dash/usePathValidation.js +0 -34
- package/dist/dash/windowHelpers.d.ts +0 -10
- package/dist/dash/windowHelpers.js +0 -43
- package/dist/flip-ui/dist/assets/index-DYY1gRRa.css +0 -1
- package/dist/flip-ui/dist/assets/index-KAtdqB2p.js +0 -217
- package/dist/flip-ui/dist/index.html +0 -13
- package/dist/ui/prompts.d.ts +0 -47
- package/dist/ui/prompts.js +0 -328
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runTui(workspaceRoot: string): Promise<void>;
|
package/dist/tui/App.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useCallback } from 'react';
|
|
3
|
+
import { render, Box, Text, useInput, useApp } from 'ink';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import * as os from 'os';
|
|
6
|
+
import { GitAdapter } from '../adapters/GitAdapter.js';
|
|
7
|
+
import { loadConfig, getWorktreeCopyPatterns } from '../config.js';
|
|
8
|
+
import { copyFilesWithPatterns } from '../fileUtils.js';
|
|
9
|
+
const git = new GitAdapter();
|
|
10
|
+
function shorten(p) {
|
|
11
|
+
const home = os.homedir();
|
|
12
|
+
return p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
13
|
+
}
|
|
14
|
+
function App({ initialWorktrees, root }) {
|
|
15
|
+
const { exit } = useApp();
|
|
16
|
+
const [view, setView] = useState('list');
|
|
17
|
+
const [worktrees, setWorktrees] = useState(initialWorktrees);
|
|
18
|
+
const [cursor, setCursor] = useState(0);
|
|
19
|
+
const [input, setInput] = useState('');
|
|
20
|
+
const [msg, setMsg] = useState(null);
|
|
21
|
+
const [busy, setBusy] = useState(false);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!msg)
|
|
24
|
+
return;
|
|
25
|
+
const t = setTimeout(() => setMsg(null), 2000);
|
|
26
|
+
return () => clearTimeout(t);
|
|
27
|
+
}, [msg]);
|
|
28
|
+
const refresh = useCallback(async () => {
|
|
29
|
+
const wts = await git.listWorktrees(root);
|
|
30
|
+
setWorktrees(wts);
|
|
31
|
+
setCursor(c => Math.min(c, Math.max(0, wts.length - 1)));
|
|
32
|
+
}, [root]);
|
|
33
|
+
useInput((ch, key) => {
|
|
34
|
+
if (busy)
|
|
35
|
+
return;
|
|
36
|
+
if (view === 'list') {
|
|
37
|
+
if (key.upArrow) {
|
|
38
|
+
setCursor(c => Math.max(0, c - 1));
|
|
39
|
+
}
|
|
40
|
+
else if (key.downArrow) {
|
|
41
|
+
setCursor(c => Math.min(worktrees.length - 1, c + 1));
|
|
42
|
+
}
|
|
43
|
+
else if (key.return && worktrees.length > 0) {
|
|
44
|
+
process.stdout.write(worktrees[cursor].path + '\n');
|
|
45
|
+
exit();
|
|
46
|
+
}
|
|
47
|
+
else if (ch === 'n') {
|
|
48
|
+
setView('create');
|
|
49
|
+
setInput('');
|
|
50
|
+
}
|
|
51
|
+
else if (ch === 'd' && worktrees.length > 0) {
|
|
52
|
+
setView('delete');
|
|
53
|
+
}
|
|
54
|
+
else if (ch === 'q' || key.escape) {
|
|
55
|
+
exit();
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (view === 'create') {
|
|
60
|
+
if (key.escape) {
|
|
61
|
+
setView('list');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (key.return && input.trim()) {
|
|
65
|
+
const name = input.trim();
|
|
66
|
+
setBusy(true);
|
|
67
|
+
const base = path.join(path.dirname(root), `${path.basename(root)}.worktree`);
|
|
68
|
+
const wtPath = path.join(base, name);
|
|
69
|
+
git.createWorktree(wtPath, name, root)
|
|
70
|
+
.then(async () => {
|
|
71
|
+
const config = await loadConfig(root);
|
|
72
|
+
const patterns = getWorktreeCopyPatterns(config);
|
|
73
|
+
if (patterns.length > 0) {
|
|
74
|
+
await copyFilesWithPatterns(root, wtPath, patterns);
|
|
75
|
+
}
|
|
76
|
+
process.stdout.write(wtPath + '\n');
|
|
77
|
+
exit();
|
|
78
|
+
})
|
|
79
|
+
.catch((e) => {
|
|
80
|
+
setMsg({ text: e.message, color: 'red' });
|
|
81
|
+
setView('list');
|
|
82
|
+
setBusy(false);
|
|
83
|
+
});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (key.backspace || key.delete) {
|
|
87
|
+
setInput(v => v.slice(0, -1));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (ch && !key.ctrl && !key.meta) {
|
|
91
|
+
setInput(v => v + ch);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (view === 'delete') {
|
|
96
|
+
if (ch === 'y' || key.return) {
|
|
97
|
+
const target = worktrees[cursor];
|
|
98
|
+
setBusy(true);
|
|
99
|
+
git.removeWorktree(target.path, root, true)
|
|
100
|
+
.then(() => git.deleteBranch(target.branch, root, true))
|
|
101
|
+
.then(async () => {
|
|
102
|
+
setMsg({ text: `Deleted ${target.branch}`, color: 'green' });
|
|
103
|
+
await refresh();
|
|
104
|
+
setBusy(false);
|
|
105
|
+
setView('list');
|
|
106
|
+
})
|
|
107
|
+
.catch((e) => {
|
|
108
|
+
setMsg({ text: e.message, color: 'red' });
|
|
109
|
+
setBusy(false);
|
|
110
|
+
setView('list');
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
else if (ch === 'n' || key.escape) {
|
|
114
|
+
setView('list');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Code Squad" }), busy && _jsx(Text, { color: "yellow", children: " ..." })] }), view === 'list' && (worktrees.length === 0 ? (_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "No worktrees yet. Press " }), _jsx(Text, { color: "yellow", children: "n" }), _jsx(Text, { dimColor: true, children: " to create one." })] })) : (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: worktrees.map((wt, i) => (_jsxs(Box, { children: [_jsx(Text, { color: i === cursor ? 'cyan' : undefined, children: i === cursor ? '❯ ' : ' ' }), _jsx(Text, { bold: i === cursor, children: wt.branch.padEnd(20) }), _jsxs(Text, { dimColor: true, children: [" ", shorten(wt.path)] })] }, wt.path))) }))), view === 'create' && (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { bold: true, children: "New Worktree" }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: '> ' }), _jsx(Text, { color: "cyan", children: input }), _jsx(Text, { dimColor: true, children: '█' })] })] })), view === 'delete' && worktrees[cursor] && (_jsx(Box, { flexDirection: "column", marginBottom: 1, children: _jsxs(Text, { children: ["Delete ", _jsx(Text, { bold: true, color: "yellow", children: worktrees[cursor].branch }), "?"] }) })), msg && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: msg.color, children: msg.text }) })), _jsxs(Box, { gap: 2, children: [view === 'list' && (_jsxs(_Fragment, { children: [worktrees.length > 0 && (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: '↑↓' }), " ", _jsx(Text, { dimColor: true, children: "navigate" })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: '↵' }), " ", _jsx(Text, { dimColor: true, children: "switch" })] })] })), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "n" }), " ", _jsx(Text, { dimColor: true, children: "new" })] }), worktrees.length > 0 && (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "d" }), " ", _jsx(Text, { dimColor: true, children: "delete" })] })), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "q" }), " ", _jsx(Text, { dimColor: true, children: "quit" })] })] })), view === 'create' && (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: '↵' }), " ", _jsx(Text, { dimColor: true, children: "create" })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "esc" }), " ", _jsx(Text, { dimColor: true, children: "cancel" })] })] })), view === 'delete' && (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "y" }), " ", _jsx(Text, { dimColor: true, children: "confirm" })] }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "n" }), " ", _jsx(Text, { dimColor: true, children: "cancel" })] })] }))] })] }));
|
|
119
|
+
}
|
|
120
|
+
export async function runTui(workspaceRoot) {
|
|
121
|
+
const worktrees = await git.listWorktrees(workspaceRoot);
|
|
122
|
+
const { waitUntilExit } = render(_jsx(App, { initialWorktrees: worktrees, root: workspaceRoot }), { stdout: process.stderr });
|
|
123
|
+
await waitUntilExit();
|
|
124
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-squad-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"csq": "./dist/index.js"
|
|
@@ -10,12 +10,10 @@
|
|
|
10
10
|
"dist"
|
|
11
11
|
],
|
|
12
12
|
"scripts": {
|
|
13
|
-
"build": "tsc && npm run bundle
|
|
14
|
-
"bundle": "esbuild dist/index.js --bundle --platform=node --format=esm --outfile=dist/index.js --allow-overwrite --external:@inquirer/core --external:@inquirer/prompts --external:chalk --external:chokidar --external:express --external:cors --external:clipboardy --external:open --external:fast-glob --external:ink --external:
|
|
15
|
-
"build:flip-ui": "cd flip-ui && pnpm build && mkdir -p ../dist/flip-ui && cp -r dist ../dist/flip-ui/",
|
|
13
|
+
"build": "tsc && npm run bundle",
|
|
14
|
+
"bundle": "esbuild dist/index.js --bundle --platform=node --format=esm --outfile=dist/index.js --allow-overwrite --external:@inquirer/core --external:@inquirer/prompts --external:chalk --external:chokidar --external:express --external:cors --external:clipboardy --external:open --external:fast-glob --external:ink --external:react --external:react/jsx-runtime",
|
|
16
15
|
"build:watch": "tsc --watch",
|
|
17
16
|
"dev": "tsx src/index.ts",
|
|
18
|
-
"dev:flip": "pnpm build:flip-ui && tsx src/index.ts flip",
|
|
19
17
|
"type-check": "tsc --noEmit",
|
|
20
18
|
"clean": "rimraf dist",
|
|
21
19
|
"prepublishOnly": "npm run clean && npm run build"
|
|
@@ -29,17 +27,16 @@
|
|
|
29
27
|
"cors": "^2.8.5",
|
|
30
28
|
"express": "^4.18.2",
|
|
31
29
|
"fast-glob": "^3.3.3",
|
|
32
|
-
"ink": "^
|
|
33
|
-
"ink-text-input": "^6.0.0",
|
|
30
|
+
"ink": "^6.7.0",
|
|
34
31
|
"open": "^10.0.3",
|
|
35
|
-
"react": "^
|
|
32
|
+
"react": "^19.2.4"
|
|
36
33
|
},
|
|
37
34
|
"devDependencies": {
|
|
38
35
|
"@code-squad/core": "workspace:*",
|
|
39
36
|
"@types/cors": "^2.8.17",
|
|
40
37
|
"@types/express": "^4.17.21",
|
|
41
38
|
"@types/node": "20.x",
|
|
42
|
-
"@types/react": "^
|
|
39
|
+
"@types/react": "^19.2.14",
|
|
43
40
|
"esbuild": "^0.27.2",
|
|
44
41
|
"rimraf": "^6.1.2",
|
|
45
42
|
"tsx": "^4.21.0",
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import type { TmuxWindowInfo } from './types.js';
|
|
2
|
-
import { TmuxAdapter } from './TmuxAdapter.js';
|
|
3
|
-
interface DashboardProps {
|
|
4
|
-
workspaceRoot: string;
|
|
5
|
-
repoName: string;
|
|
6
|
-
currentBranch: string;
|
|
7
|
-
initialWindows: TmuxWindowInfo[];
|
|
8
|
-
tmuxAdapter: TmuxAdapter;
|
|
9
|
-
dashWindowIndex: number;
|
|
10
|
-
paneHeight: number;
|
|
11
|
-
}
|
|
12
|
-
export declare function runInkDashboard(config: DashboardProps): Promise<void>;
|
|
13
|
-
export {};
|
|
@@ -1,442 +0,0 @@
|
|
|
1
|
-
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
3
|
-
import { render, Box, Text, useInput, useStdin } from 'ink';
|
|
4
|
-
import TextInput from 'ink-text-input';
|
|
5
|
-
import { loadAllWindows, deleteWindowById } from './windowHelpers.js';
|
|
6
|
-
import { createThread, deleteThread } from './threadHelpers.js';
|
|
7
|
-
import { expandTilde } from './pathUtils.js';
|
|
8
|
-
import { useDirectorySuggestions } from './useDirectorySuggestions.js';
|
|
9
|
-
import { usePathValidation } from './usePathValidation.js';
|
|
10
|
-
function parseMouseEvent(data) {
|
|
11
|
-
const match = data.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/);
|
|
12
|
-
if (!match)
|
|
13
|
-
return null;
|
|
14
|
-
return {
|
|
15
|
-
button: parseInt(match[1], 10),
|
|
16
|
-
col: parseInt(match[2], 10),
|
|
17
|
-
row: parseInt(match[3], 10),
|
|
18
|
-
release: match[4] === 'm',
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
// 마우스 훅
|
|
22
|
-
function useMouse(onMouseClick, enabled = true) {
|
|
23
|
-
const { stdin } = useStdin();
|
|
24
|
-
useEffect(() => {
|
|
25
|
-
if (!enabled) {
|
|
26
|
-
process.stdout.write('\x1b[?1006l');
|
|
27
|
-
process.stdout.write('\x1b[?1000l');
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
process.stdout.write('\x1b[?1000h');
|
|
31
|
-
process.stdout.write('\x1b[?1006h');
|
|
32
|
-
const handleData = (data) => {
|
|
33
|
-
const str = data.toString();
|
|
34
|
-
if (str.includes('\x1b[<')) {
|
|
35
|
-
const mouseEvent = parseMouseEvent(str);
|
|
36
|
-
if (mouseEvent && mouseEvent.button === 0 && !mouseEvent.release) {
|
|
37
|
-
onMouseClick(mouseEvent.row, mouseEvent.col);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
stdin?.on('data', handleData);
|
|
42
|
-
return () => {
|
|
43
|
-
process.stdout.write('\x1b[?1006l');
|
|
44
|
-
process.stdout.write('\x1b[?1000l');
|
|
45
|
-
stdin?.off('data', handleData);
|
|
46
|
-
};
|
|
47
|
-
}, [stdin, onMouseClick, enabled]);
|
|
48
|
-
}
|
|
49
|
-
// Window 카드 컴포넌트
|
|
50
|
-
function WindowCard({ window, isSelected, }) {
|
|
51
|
-
const borderColor = isSelected ? 'cyan' : 'gray';
|
|
52
|
-
const nameColor = isSelected ? 'cyan' : 'white';
|
|
53
|
-
const statusIcon = window.isActive ? '●' : '○';
|
|
54
|
-
const statusColor = window.isActive ? 'green' : 'gray';
|
|
55
|
-
const projectName = window.projectRoot
|
|
56
|
-
? window.projectRoot.split('/').slice(-1)[0]
|
|
57
|
-
: window.cwd.split('/').slice(-1)[0];
|
|
58
|
-
const threadName = window.worktreeBranch ?? window.name;
|
|
59
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: borderColor, paddingX: 1, marginBottom: 0, children: [_jsxs(Box, { children: [_jsxs(Text, { color: statusColor, children: [statusIcon, " "] }), _jsx(Text, { color: nameColor, bold: isSelected, children: window.name }), isSelected && _jsx(Text, { color: "red", children: " \u2715" })] }), _jsxs(Box, { children: [_jsx(Text, { color: "gray", children: projectName }), _jsx(Text, { color: "gray", children: " \u2192 " }), _jsx(Text, { color: "cyan", children: threadName })] })] }));
|
|
60
|
-
}
|
|
61
|
-
// 새 Window 폼 컴포넌트
|
|
62
|
-
function NewWindowForm({ windowName, onWindowNameChange, rootPath, onRootPathChange, validation, onSubmit, onCancel, }) {
|
|
63
|
-
const [focusedField, setFocusedField] = useState('name');
|
|
64
|
-
const dirSuggestions = useDirectorySuggestions();
|
|
65
|
-
useInput(async (input, key) => {
|
|
66
|
-
if (key.escape) {
|
|
67
|
-
if (dirSuggestions.isOpen) {
|
|
68
|
-
dirSuggestions.clearSuggestions();
|
|
69
|
-
}
|
|
70
|
-
else {
|
|
71
|
-
onCancel();
|
|
72
|
-
}
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
if (key.return) {
|
|
76
|
-
if (dirSuggestions.isOpen) {
|
|
77
|
-
const accepted = dirSuggestions.acceptSelected(rootPath);
|
|
78
|
-
if (accepted)
|
|
79
|
-
onRootPathChange(accepted);
|
|
80
|
-
}
|
|
81
|
-
else {
|
|
82
|
-
onSubmit();
|
|
83
|
-
}
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
if (key.tab) {
|
|
87
|
-
if (dirSuggestions.isOpen) {
|
|
88
|
-
dirSuggestions.clearSuggestions();
|
|
89
|
-
}
|
|
90
|
-
setFocusedField(prev => prev === 'name' ? 'root' : 'name');
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
if (key.upArrow) {
|
|
94
|
-
if (dirSuggestions.isOpen) {
|
|
95
|
-
dirSuggestions.selectPrev();
|
|
96
|
-
}
|
|
97
|
-
else {
|
|
98
|
-
setFocusedField('name');
|
|
99
|
-
}
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
if (key.downArrow) {
|
|
103
|
-
if (dirSuggestions.isOpen) {
|
|
104
|
-
dirSuggestions.selectNext();
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
setFocusedField('root');
|
|
108
|
-
}
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
// Auto-trigger suggestions as user types
|
|
113
|
-
const handleRootPathChange = useCallback(async (value) => {
|
|
114
|
-
onRootPathChange(value);
|
|
115
|
-
if (value.endsWith('/')) {
|
|
116
|
-
const completed = await dirSuggestions.triggerComplete(value);
|
|
117
|
-
if (completed)
|
|
118
|
-
onRootPathChange(completed);
|
|
119
|
-
}
|
|
120
|
-
else if (value.length > 1 && value.includes('/')) {
|
|
121
|
-
await dirSuggestions.triggerComplete(value);
|
|
122
|
-
}
|
|
123
|
-
else {
|
|
124
|
-
dirSuggestions.clearSuggestions();
|
|
125
|
-
}
|
|
126
|
-
}, [dirSuggestions, onRootPathChange]);
|
|
127
|
-
// Validation indicator
|
|
128
|
-
let validationIcon = '';
|
|
129
|
-
let validationColor;
|
|
130
|
-
let validationText = '';
|
|
131
|
-
if (validation) {
|
|
132
|
-
if (validation.status === 'valid') {
|
|
133
|
-
if (validation.isGitRepo) {
|
|
134
|
-
validationIcon = '✓';
|
|
135
|
-
validationColor = 'green';
|
|
136
|
-
validationText = 'git repo';
|
|
137
|
-
}
|
|
138
|
-
else {
|
|
139
|
-
validationIcon = '✗';
|
|
140
|
-
validationColor = 'red';
|
|
141
|
-
validationText = 'not a git repo';
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
else if (validation.status === 'creatable') {
|
|
145
|
-
validationIcon = '✗';
|
|
146
|
-
validationColor = 'red';
|
|
147
|
-
validationText = 'not a git repo';
|
|
148
|
-
}
|
|
149
|
-
else {
|
|
150
|
-
validationIcon = '✗';
|
|
151
|
-
validationColor = 'red';
|
|
152
|
-
validationText = 'invalid path';
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsx(Text, { color: "yellow", bold: true, children: "+ New Window" }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: focusedField === 'name' ? 'cyan' : undefined, children: "Name: " }), _jsx(TextInput, { value: windowName, onChange: focusedField === 'name' ? onWindowNameChange : () => { }, placeholder: "window-name", focus: focusedField === 'name' })] }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: focusedField === 'root' ? 'cyan' : 'gray', children: "Root: " }), focusedField === 'root' ? (_jsx(TextInput, { value: rootPath, onChange: handleRootPathChange, placeholder: "/path/to/dir", focus: true })) : (_jsx(Text, { color: "gray", children: rootPath }))] }), validation && (_jsxs(Box, { children: [_jsx(Text, { children: " " }), _jsxs(Text, { color: validationColor, children: [validationIcon, " ", validationText] })] })), dirSuggestions.isOpen && dirSuggestions.visibleSuggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [dirSuggestions.visibleSuggestions.map((s) => (_jsx(Box, { children: _jsxs(Text, { color: s.isSelected ? 'cyan' : 'gray', children: [s.isSelected ? ' > ' : ' ', s.name, "/"] }) }, s.name))), dirSuggestions.hasMore && (_jsx(Text, { color: "gray", children: " \u2191\u2193 for more" }))] })), dirSuggestions.isOpen ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: "\u2191\u2193:select Enter:pick Esc:close" }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: "Tab:field Enter:ok Esc:cancel" }) }))] }));
|
|
156
|
-
}
|
|
157
|
-
// 삭제 확인 컴포넌트
|
|
158
|
-
function DeleteConfirm({ windowName, onConfirm, onCancel, }) {
|
|
159
|
-
useInput((input, key) => {
|
|
160
|
-
if (input === 'y' || input === 'Y') {
|
|
161
|
-
onConfirm();
|
|
162
|
-
}
|
|
163
|
-
else if (input === 'n' || input === 'N' || key.escape) {
|
|
164
|
-
onCancel();
|
|
165
|
-
}
|
|
166
|
-
});
|
|
167
|
-
return (_jsxs(Box, { borderStyle: "round", borderColor: "red", paddingX: 1, children: [_jsxs(Text, { color: "red", children: ["Delete \"", windowName, "\"? "] }), _jsx(Text, { color: "gray", children: "(y/n)" })] }));
|
|
168
|
-
}
|
|
169
|
-
// 힌트 바 컴포넌트
|
|
170
|
-
function HintBar({ mode }) {
|
|
171
|
-
if (mode === 'new-window') {
|
|
172
|
-
return null;
|
|
173
|
-
}
|
|
174
|
-
return (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: "\u2191\u2193/jk: nav Enter: switch r: refresh q: detach" }) }));
|
|
175
|
-
}
|
|
176
|
-
// 메인 대시보드 컴포넌트
|
|
177
|
-
function Dashboard({ workspaceRoot, repoName, currentBranch, initialWindows, tmuxAdapter, dashWindowIndex, paneHeight, }) {
|
|
178
|
-
const [windows, setWindows] = useState(initialWindows);
|
|
179
|
-
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
180
|
-
const [inputMode, setInputMode] = useState('normal');
|
|
181
|
-
const [newWindowName, setNewWindowName] = useState('');
|
|
182
|
-
const [rootPath, setRootPath] = useState(workspaceRoot);
|
|
183
|
-
// Dynamic git repo detection from path validation
|
|
184
|
-
const { validation, isGitRepo: formIsGitRepo } = usePathValidation(rootPath);
|
|
185
|
-
// name 변경 핸들러 (공백 제거)
|
|
186
|
-
const handleWindowNameChange = useCallback((value) => {
|
|
187
|
-
const sanitized = value.replace(/\s/g, '');
|
|
188
|
-
setNewWindowName(sanitized);
|
|
189
|
-
}, []);
|
|
190
|
-
const [status, setStatus] = useState('');
|
|
191
|
-
const [isProcessing, setIsProcessing] = useState(false);
|
|
192
|
-
// Track which window's content is currently shown in the right pane
|
|
193
|
-
const activeWindowIdRef = useRef(null);
|
|
194
|
-
const statusTimerRef = useRef(null);
|
|
195
|
-
// Auto-clear status message after 2 seconds
|
|
196
|
-
const showStatus = useCallback((msg) => {
|
|
197
|
-
if (statusTimerRef.current)
|
|
198
|
-
clearTimeout(statusTimerRef.current);
|
|
199
|
-
setStatus(msg);
|
|
200
|
-
statusTimerRef.current = setTimeout(() => setStatus(''), 2000);
|
|
201
|
-
}, []);
|
|
202
|
-
// Window 목록 새로고침
|
|
203
|
-
const refreshWindows = useCallback(async () => {
|
|
204
|
-
const updatedWindows = await loadAllWindows(tmuxAdapter, dashWindowIndex);
|
|
205
|
-
setWindows(updatedWindows);
|
|
206
|
-
return { windows: updatedWindows };
|
|
207
|
-
}, [tmuxAdapter, dashWindowIndex]);
|
|
208
|
-
// Auto-select first thread on restore (mount)
|
|
209
|
-
useEffect(() => {
|
|
210
|
-
if (initialWindows.length === 0)
|
|
211
|
-
return;
|
|
212
|
-
const first = initialWindows[0];
|
|
213
|
-
if (first) {
|
|
214
|
-
void handleSelectWindow(first);
|
|
215
|
-
}
|
|
216
|
-
}, []);
|
|
217
|
-
// 레이아웃 행 계산 (마우스 클릭 매핑용)
|
|
218
|
-
const HEADER_ROWS = 3;
|
|
219
|
-
const NEW_WINDOW_ROWS = 3;
|
|
220
|
-
const WINDOW_START_ROW = HEADER_ROWS + NEW_WINDOW_ROWS + 2;
|
|
221
|
-
const WINDOW_HEIGHT = 4;
|
|
222
|
-
// 마우스 클릭 핸들러
|
|
223
|
-
const handleMouseClick = useCallback((row, col) => {
|
|
224
|
-
if (isProcessing || inputMode !== 'normal')
|
|
225
|
-
return;
|
|
226
|
-
// 새 Window 버튼 클릭
|
|
227
|
-
if (row >= HEADER_ROWS + 1 && row <= HEADER_ROWS + NEW_WINDOW_ROWS + 1) {
|
|
228
|
-
setInputMode('new-window');
|
|
229
|
-
setNewWindowName('');
|
|
230
|
-
setRootPath(workspaceRoot);
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
// Window 리스트 클릭
|
|
234
|
-
if (row >= WINDOW_START_ROW && windows.length > 0) {
|
|
235
|
-
const windowIndex = Math.floor((row - WINDOW_START_ROW) / WINDOW_HEIGHT);
|
|
236
|
-
if (windowIndex >= 0 && windowIndex < windows.length) {
|
|
237
|
-
setSelectedIndex(windowIndex);
|
|
238
|
-
// 클릭하면 바로 전환
|
|
239
|
-
void handleSelectWindow(windows[windowIndex]);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}, [isProcessing, inputMode, windows, workspaceRoot]);
|
|
243
|
-
// 마우스 이벤트 (normal 모드에서만 활성화)
|
|
244
|
-
useMouse(handleMouseClick, inputMode === 'normal');
|
|
245
|
-
// 오른쪽 pane 찾기 (대시보드 pane index 0 제외)
|
|
246
|
-
const findRightPane = async () => {
|
|
247
|
-
const panes = await tmuxAdapter.listPanes();
|
|
248
|
-
return panes.find(p => p.index !== 0);
|
|
249
|
-
};
|
|
250
|
-
// Window 선택 (오른쪽 pane에 swap)
|
|
251
|
-
const handleSelectWindow = async (win) => {
|
|
252
|
-
try {
|
|
253
|
-
// Already showing this window — just focus the right pane
|
|
254
|
-
if (win.windowId === activeWindowIdRef.current) {
|
|
255
|
-
const rightPane = await findRightPane();
|
|
256
|
-
if (rightPane)
|
|
257
|
-
await tmuxAdapter.selectPane(rightPane.id);
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
// Swap current right pane back to its original window
|
|
261
|
-
if (activeWindowIdRef.current) {
|
|
262
|
-
const rightPane = await findRightPane();
|
|
263
|
-
if (rightPane) {
|
|
264
|
-
try {
|
|
265
|
-
await tmuxAdapter.swapPaneWithWindow(activeWindowIdRef.current, rightPane.id);
|
|
266
|
-
}
|
|
267
|
-
catch {
|
|
268
|
-
// Original window may have been killed externally
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
// Swap selected window's pane into the right position
|
|
273
|
-
const rightPane = await findRightPane();
|
|
274
|
-
if (rightPane) {
|
|
275
|
-
await tmuxAdapter.swapPaneWithWindow(win.windowId, rightPane.id);
|
|
276
|
-
activeWindowIdRef.current = win.windowId;
|
|
277
|
-
await tmuxAdapter.selectPane((await findRightPane()).id);
|
|
278
|
-
}
|
|
279
|
-
showStatus(`Switched: ${win.name}`);
|
|
280
|
-
}
|
|
281
|
-
catch (error) {
|
|
282
|
-
showStatus(`Error: ${error.message}`);
|
|
283
|
-
}
|
|
284
|
-
};
|
|
285
|
-
// 키보드 입력 처리 (normal 모드)
|
|
286
|
-
useInput(async (input, key) => {
|
|
287
|
-
// Allow Escape to cancel during processing
|
|
288
|
-
if (isProcessing) {
|
|
289
|
-
if (key.escape) {
|
|
290
|
-
setIsProcessing(false);
|
|
291
|
-
setInputMode('normal');
|
|
292
|
-
showStatus('Cancelled');
|
|
293
|
-
}
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
if (inputMode !== 'normal')
|
|
297
|
-
return;
|
|
298
|
-
if (input === 'j' || key.downArrow) {
|
|
299
|
-
setSelectedIndex(prev => (prev + 1) % Math.max(windows.length, 1));
|
|
300
|
-
}
|
|
301
|
-
else if (input === 'k' || key.upArrow) {
|
|
302
|
-
setSelectedIndex(prev => (prev - 1 + Math.max(windows.length, 1)) % Math.max(windows.length, 1));
|
|
303
|
-
}
|
|
304
|
-
else if (input === 'q') {
|
|
305
|
-
try {
|
|
306
|
-
// Swap active pane back to its original window before quitting
|
|
307
|
-
if (activeWindowIdRef.current) {
|
|
308
|
-
const rightPane = await findRightPane();
|
|
309
|
-
if (rightPane) {
|
|
310
|
-
try {
|
|
311
|
-
await tmuxAdapter.swapPaneWithWindow(activeWindowIdRef.current, rightPane.id);
|
|
312
|
-
}
|
|
313
|
-
catch {
|
|
314
|
-
// Original window may have been killed externally
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
// Detach + kill dashboard window in one atomic tmux command.
|
|
319
|
-
// Thread windows stay alive in the detached session.
|
|
320
|
-
const sessionName = `csq-${repoName}`;
|
|
321
|
-
await tmuxAdapter.detachAndKillWindow(sessionName, dashWindowIndex);
|
|
322
|
-
}
|
|
323
|
-
catch {
|
|
324
|
-
// Detach/kill may race, ignore
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
else if (key.return) {
|
|
328
|
-
const selected = windows[selectedIndex];
|
|
329
|
-
if (selected) {
|
|
330
|
-
await handleSelectWindow(selected);
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
else if (input === 'n' || input === '+') {
|
|
334
|
-
setInputMode('new-window');
|
|
335
|
-
setNewWindowName('');
|
|
336
|
-
setRootPath(workspaceRoot);
|
|
337
|
-
}
|
|
338
|
-
else if (input === 'd') {
|
|
339
|
-
if (windows[selectedIndex]) {
|
|
340
|
-
setInputMode('confirm-delete');
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
else if (input === 'r') {
|
|
344
|
-
process.stdout.write('\x1b[2J\x1b[H');
|
|
345
|
-
process.stdout.emit('resize');
|
|
346
|
-
await refreshWindows();
|
|
347
|
-
showStatus('Refreshed');
|
|
348
|
-
}
|
|
349
|
-
});
|
|
350
|
-
// 새 Window 생성
|
|
351
|
-
const handleCreateWindow = async () => {
|
|
352
|
-
if (!newWindowName.trim()) {
|
|
353
|
-
showStatus('Window name required');
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
|
-
if (!validation || !formIsGitRepo) {
|
|
357
|
-
showStatus('Root must be a git repo');
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
setIsProcessing(true);
|
|
361
|
-
setStatus(`Creating ${newWindowName}...`);
|
|
362
|
-
try {
|
|
363
|
-
const expandedRoot = expandTilde(rootPath);
|
|
364
|
-
const newThread = await createThread(workspaceRoot, newWindowName.trim(), expandedRoot);
|
|
365
|
-
const newWindowId = await tmuxAdapter.createNewWindow(newThread.path, newWindowName.trim());
|
|
366
|
-
const { windows: updatedWindows } = await refreshWindows();
|
|
367
|
-
setInputMode('normal');
|
|
368
|
-
setNewWindowName('');
|
|
369
|
-
showStatus(`Created: ${newWindowName}`);
|
|
370
|
-
// Auto-select the new window into the right pane
|
|
371
|
-
const newWin = updatedWindows.find(w => w.windowId === newWindowId);
|
|
372
|
-
if (newWin) {
|
|
373
|
-
await handleSelectWindow(newWin);
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
catch (error) {
|
|
377
|
-
showStatus(`Error: ${error.message}`);
|
|
378
|
-
}
|
|
379
|
-
setIsProcessing(false);
|
|
380
|
-
};
|
|
381
|
-
// Window 삭제
|
|
382
|
-
const handleDeleteWindow = async () => {
|
|
383
|
-
const selected = windows[selectedIndex];
|
|
384
|
-
if (!selected)
|
|
385
|
-
return;
|
|
386
|
-
setIsProcessing(true);
|
|
387
|
-
setStatus(`Deleting ${selected.name}...`);
|
|
388
|
-
try {
|
|
389
|
-
// If this window's pane is currently in the right pane, swap it back first
|
|
390
|
-
if (selected.windowId === activeWindowIdRef.current) {
|
|
391
|
-
const rightPane = await findRightPane();
|
|
392
|
-
if (rightPane) {
|
|
393
|
-
try {
|
|
394
|
-
await tmuxAdapter.swapPaneWithWindow(selected.windowId, rightPane.id);
|
|
395
|
-
}
|
|
396
|
-
catch { /* ignore */ }
|
|
397
|
-
}
|
|
398
|
-
activeWindowIdRef.current = null;
|
|
399
|
-
}
|
|
400
|
-
// Kill the tmux window
|
|
401
|
-
await deleteWindowById(tmuxAdapter, selected.windowId);
|
|
402
|
-
// If it's a worktree-based window, also remove the worktree + branch
|
|
403
|
-
if (selected.worktreeBranch) {
|
|
404
|
-
try {
|
|
405
|
-
await deleteThread(workspaceRoot, {
|
|
406
|
-
id: selected.cwd,
|
|
407
|
-
name: selected.worktreeBranch,
|
|
408
|
-
path: selected.cwd,
|
|
409
|
-
branch: selected.worktreeBranch,
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
catch {
|
|
413
|
-
// Worktree cleanup failed — window is already deleted
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
const { windows: updatedWindows } = await refreshWindows();
|
|
417
|
-
const newIndex = Math.min(selectedIndex, Math.max(0, updatedWindows.length - 1));
|
|
418
|
-
setSelectedIndex(newIndex);
|
|
419
|
-
// Auto-select the previous/next window into the right pane
|
|
420
|
-
const fallbackWin = updatedWindows[newIndex];
|
|
421
|
-
if (fallbackWin) {
|
|
422
|
-
await handleSelectWindow(fallbackWin);
|
|
423
|
-
}
|
|
424
|
-
setInputMode('normal');
|
|
425
|
-
showStatus(`Deleted: ${selected.name}`);
|
|
426
|
-
}
|
|
427
|
-
catch (error) {
|
|
428
|
-
showStatus(`Error: ${error.message}`);
|
|
429
|
-
setInputMode('normal');
|
|
430
|
-
}
|
|
431
|
-
setIsProcessing(false);
|
|
432
|
-
};
|
|
433
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, height: paneHeight, children: [_jsxs(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, marginBottom: 1, children: [_jsx(Text, { bold: true, color: "white", children: repoName }), _jsxs(Text, { color: "cyan", children: [" [", currentBranch, "]"] }), _jsxs(Text, { color: "gray", children: [" (", windows.length, ")"] })] }), inputMode === 'new-window' ? (_jsx(NewWindowForm, { windowName: newWindowName, onWindowNameChange: handleWindowNameChange, rootPath: rootPath, onRootPathChange: setRootPath, validation: validation, onSubmit: handleCreateWindow, onCancel: () => {
|
|
434
|
-
setInputMode('normal');
|
|
435
|
-
setNewWindowName('');
|
|
436
|
-
} })) : (_jsx(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, marginBottom: 1, children: _jsx(Text, { color: "gray", children: "+ New Window (press + or n)" }) })), inputMode === 'confirm-delete' && windows[selectedIndex] && (_jsx(DeleteConfirm, { windowName: windows[selectedIndex].name, onConfirm: handleDeleteWindow, onCancel: () => setInputMode('normal') })), _jsx(Box, { flexDirection: "column", marginTop: 1, children: windows.length === 0 ? (_jsx(Box, { borderStyle: "round", borderColor: "gray", paddingX: 1, children: _jsx(Text, { color: "gray", children: "No windows yet" }) })) : (windows.map((win, i) => (_jsx(WindowCard, { window: win, isSelected: i === selectedIndex }, win.windowId)))) }), _jsx(HintBar, { mode: inputMode }), status && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "yellow", children: status }) }))] }));
|
|
437
|
-
}
|
|
438
|
-
// 대시보드 실행
|
|
439
|
-
export async function runInkDashboard(config) {
|
|
440
|
-
render(_jsx(Dashboard, { ...config }));
|
|
441
|
-
await new Promise(() => { });
|
|
442
|
-
}
|