code-squad-cli 1.2.22 → 1.3.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/adapters/GitAdapter.js +18 -4
- package/dist/dash/InkDashboard.d.ts +13 -0
- package/dist/dash/InkDashboard.js +442 -0
- package/dist/dash/TmuxAdapter.d.ts +233 -0
- package/dist/dash/TmuxAdapter.js +520 -0
- package/dist/dash/index.d.ts +4 -0
- package/dist/dash/index.js +216 -0
- package/dist/dash/pathUtils.d.ts +27 -0
- package/dist/dash/pathUtils.js +70 -0
- package/dist/dash/threadHelpers.d.ts +9 -0
- package/dist/dash/threadHelpers.js +37 -0
- package/dist/dash/types.d.ts +42 -0
- package/dist/dash/types.js +1 -0
- package/dist/dash/useDirectorySuggestions.d.ts +23 -0
- package/dist/dash/useDirectorySuggestions.js +136 -0
- package/dist/dash/usePathValidation.d.ts +9 -0
- package/dist/dash/usePathValidation.js +34 -0
- package/dist/dash/windowHelpers.d.ts +10 -0
- package/dist/dash/windowHelpers.js +43 -0
- package/dist/index.js +1376 -78
- package/package.json +7 -3
|
@@ -14,8 +14,20 @@ export class GitAdapter {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
async getCurrentBranch(workspaceRoot) {
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
try {
|
|
18
|
+
const { stdout } = await exec(`cd "${workspaceRoot}" && git rev-parse --abbrev-ref HEAD`, execOptions);
|
|
19
|
+
return stdout.trim();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// No commits yet — fall back to symbolic-ref (e.g. "main" on fresh repos)
|
|
23
|
+
try {
|
|
24
|
+
const { stdout } = await exec(`cd "${workspaceRoot}" && git symbolic-ref --short HEAD`, execOptions);
|
|
25
|
+
return stdout.trim();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
19
31
|
}
|
|
20
32
|
async listWorktrees(workspaceRoot) {
|
|
21
33
|
try {
|
|
@@ -57,10 +69,12 @@ export class GitAdapter {
|
|
|
57
69
|
}
|
|
58
70
|
}
|
|
59
71
|
async createWorktree(worktreePath, branch, workspaceRoot) {
|
|
60
|
-
//
|
|
72
|
+
// Prune stale worktree entries (e.g. directory was deleted but still registered)
|
|
73
|
+
await exec(`cd "${workspaceRoot}" && git worktree prune`, execOptions).catch(() => { });
|
|
74
|
+
// Recursively create parent directory
|
|
61
75
|
const parentDir = worktreePath.substring(0, worktreePath.lastIndexOf('/'));
|
|
62
76
|
const mkdirCmd = parentDir ? `mkdir -p "${parentDir}" && ` : '';
|
|
63
|
-
await exec(`cd "${workspaceRoot}" && ${mkdirCmd}git worktree add "${worktreePath}" -b "${branch}"`, execOptions);
|
|
77
|
+
await exec(`cd "${workspaceRoot}" && ${mkdirCmd}git worktree add -f "${worktreePath}" -b "${branch}"`, execOptions);
|
|
64
78
|
}
|
|
65
79
|
async removeWorktree(worktreePath, workspaceRoot, force = false) {
|
|
66
80
|
const forceFlag = force ? ' --force' : '';
|
|
@@ -0,0 +1,13 @@
|
|
|
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 {};
|
|
@@ -0,0 +1,442 @@
|
|
|
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
|
+
}
|