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
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { confirm } from '@inquirer/prompts';
|
|
4
|
+
import { TmuxAdapter } from './TmuxAdapter.js';
|
|
5
|
+
import { GitAdapter } from '../adapters/GitAdapter.js';
|
|
6
|
+
import { runInkDashboard } from './InkDashboard.js';
|
|
7
|
+
import { loadAllWindows } from './windowHelpers.js';
|
|
8
|
+
const tmuxAdapter = new TmuxAdapter();
|
|
9
|
+
const gitAdapter = new GitAdapter();
|
|
10
|
+
/**
|
|
11
|
+
* tmux 자동 설치
|
|
12
|
+
*/
|
|
13
|
+
async function installTmux() {
|
|
14
|
+
const platform = process.platform;
|
|
15
|
+
const { spawn } = await import('child_process');
|
|
16
|
+
let command;
|
|
17
|
+
let args;
|
|
18
|
+
if (platform === 'darwin') {
|
|
19
|
+
// macOS: brew 사용
|
|
20
|
+
console.log(chalk.dim('Installing tmux via Homebrew...'));
|
|
21
|
+
command = 'brew';
|
|
22
|
+
args = ['install', 'tmux'];
|
|
23
|
+
}
|
|
24
|
+
else if (platform === 'linux') {
|
|
25
|
+
// Linux: apt 또는 yum
|
|
26
|
+
const { exec } = await import('child_process');
|
|
27
|
+
const { promisify } = await import('util');
|
|
28
|
+
const execAsync = promisify(exec);
|
|
29
|
+
// apt 사용 가능 여부 확인
|
|
30
|
+
try {
|
|
31
|
+
await execAsync('which apt-get');
|
|
32
|
+
console.log(chalk.dim('Installing tmux via apt...'));
|
|
33
|
+
command = 'sudo';
|
|
34
|
+
args = ['apt-get', 'install', '-y', 'tmux'];
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// yum 사용
|
|
38
|
+
try {
|
|
39
|
+
await execAsync('which yum');
|
|
40
|
+
console.log(chalk.dim('Installing tmux via yum...'));
|
|
41
|
+
command = 'sudo';
|
|
42
|
+
args = ['yum', 'install', '-y', 'tmux'];
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
console.error(chalk.red('Could not find apt or yum package manager'));
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
console.error(chalk.red(`Unsupported platform: ${platform}`));
|
|
52
|
+
console.error(chalk.dim('Please install tmux manually'));
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const proc = spawn(command, args, { stdio: 'inherit' });
|
|
57
|
+
proc.on('close', (code) => {
|
|
58
|
+
if (code === 0) {
|
|
59
|
+
console.log(chalk.green('✓ tmux installed successfully'));
|
|
60
|
+
resolve(true);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
console.error(chalk.red(`Installation failed with code ${code}`));
|
|
64
|
+
resolve(false);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
proc.on('error', (error) => {
|
|
68
|
+
console.error(chalk.red(`Installation error: ${error.message}`));
|
|
69
|
+
resolve(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 대시보드 모드 실행
|
|
75
|
+
*/
|
|
76
|
+
export async function runDash(workspaceRoot) {
|
|
77
|
+
const repoName = path.basename(workspaceRoot);
|
|
78
|
+
// tmux 설치 확인
|
|
79
|
+
if (!await tmuxAdapter.isTmuxAvailable()) {
|
|
80
|
+
console.log(chalk.yellow('tmux is not installed.'));
|
|
81
|
+
console.log(chalk.dim('tmux is required for the dashboard mode.'));
|
|
82
|
+
console.log('');
|
|
83
|
+
const shouldInstall = await confirm({
|
|
84
|
+
message: 'Would you like to install tmux now?',
|
|
85
|
+
default: true,
|
|
86
|
+
});
|
|
87
|
+
if (shouldInstall) {
|
|
88
|
+
const success = await installTmux();
|
|
89
|
+
if (!success) {
|
|
90
|
+
console.error(chalk.red('\nFailed to install tmux.'));
|
|
91
|
+
console.error(chalk.dim('Please install manually:'));
|
|
92
|
+
console.error(chalk.dim(' macOS: brew install tmux'));
|
|
93
|
+
console.error(chalk.dim(' Ubuntu: sudo apt install tmux'));
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
console.log('');
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
console.log(chalk.dim('\nTo use dashboard mode, install tmux:'));
|
|
100
|
+
console.error(chalk.dim(' macOS: brew install tmux'));
|
|
101
|
+
console.error(chalk.dim(' Ubuntu: sudo apt install tmux'));
|
|
102
|
+
console.log(chalk.dim('\nOr use legacy mode: csq --legacy'));
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// tmux 세션 내부인지 확인
|
|
107
|
+
if (!tmuxAdapter.isInsideTmux()) {
|
|
108
|
+
// tmux 세션 외부에서 실행됨 → 새 세션 생성 또는 기존 세션에 attach
|
|
109
|
+
const sessionName = `csq-${repoName}`;
|
|
110
|
+
try {
|
|
111
|
+
// 기존 세션 확인
|
|
112
|
+
const isNewSession = await tmuxAdapter.ensureSession(sessionName, workspaceRoot);
|
|
113
|
+
// UX 설정 적용 (마우스 모드, 테두리 강조 등)
|
|
114
|
+
await tmuxAdapter.applyUXSettings();
|
|
115
|
+
// 현재 실행 중인 스크립트의 절대 경로 사용
|
|
116
|
+
const scriptPath = process.argv[1];
|
|
117
|
+
const nodeCmd = `node "${scriptPath}"`;
|
|
118
|
+
if (isNewSession) {
|
|
119
|
+
console.log(chalk.dim('Starting new tmux session...'));
|
|
120
|
+
// 세션 내에서 같은 스크립트 재실행
|
|
121
|
+
await tmuxAdapter.sendKeys(`${sessionName}:0`, nodeCmd);
|
|
122
|
+
await tmuxAdapter.sendEnter(`${sessionName}:0`);
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
console.log(chalk.dim('Restoring dashboard...'));
|
|
126
|
+
// Kill leftover dashboard window at index 0 if it exists
|
|
127
|
+
try {
|
|
128
|
+
await tmuxAdapter.killWindow(`${sessionName}:0`);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Window 0 may not exist, ignore
|
|
132
|
+
}
|
|
133
|
+
// 기존 세션에 대시보드 window를 맨 앞에 생성
|
|
134
|
+
await tmuxAdapter.createWindowAtIndex(sessionName, 0, workspaceRoot);
|
|
135
|
+
// Make window 0 the active window so csq's tmux commands target it
|
|
136
|
+
await tmuxAdapter.selectWindow(`${sessionName}:0`);
|
|
137
|
+
// 새 window에서 csq 재실행
|
|
138
|
+
await tmuxAdapter.sendKeys(`${sessionName}:0`, nodeCmd);
|
|
139
|
+
await tmuxAdapter.sendEnter(`${sessionName}:0`);
|
|
140
|
+
}
|
|
141
|
+
// attach
|
|
142
|
+
const { spawn } = await import('child_process');
|
|
143
|
+
const tmux = spawn('tmux', ['attach-session', '-t', sessionName], {
|
|
144
|
+
stdio: 'inherit',
|
|
145
|
+
});
|
|
146
|
+
await new Promise((resolve, reject) => {
|
|
147
|
+
tmux.on('close', (code) => {
|
|
148
|
+
if (code === 0) {
|
|
149
|
+
resolve();
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
reject(new Error(`tmux exited with code ${code}`));
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
tmux.on('error', reject);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
console.error(chalk.red(`Failed to start tmux session: ${error.message}`));
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// tmux 세션 내부에서 실행됨 → 대시보드 UI 실행
|
|
165
|
+
console.clear();
|
|
166
|
+
console.log(chalk.bold.cyan('Code Squad Dashboard'));
|
|
167
|
+
console.log(chalk.dim('Setting up layout...'));
|
|
168
|
+
// UX 설정 적용 (마우스 모드, 테두리 강조 등)
|
|
169
|
+
await tmuxAdapter.applyUXSettings();
|
|
170
|
+
try {
|
|
171
|
+
// 현재 pane ID 저장 (대시보드 pane)
|
|
172
|
+
const dashPaneId = await tmuxAdapter.getCurrentPaneId();
|
|
173
|
+
if (!dashPaneId) {
|
|
174
|
+
throw new Error('Could not get current pane ID');
|
|
175
|
+
}
|
|
176
|
+
// 현재 window index 저장 (대시보드 window)
|
|
177
|
+
const dashWindowIndex = await tmuxAdapter.getCurrentWindowIndex();
|
|
178
|
+
if (dashWindowIndex === null) {
|
|
179
|
+
throw new Error('Could not get current window index');
|
|
180
|
+
}
|
|
181
|
+
// 대시보드 pane 고정 리사이즈 훅 설정
|
|
182
|
+
await tmuxAdapter.setDashboardResizeHook(dashPaneId, 35);
|
|
183
|
+
// 윈도우 목록 로드 (대시보드 제외)
|
|
184
|
+
const windows = await loadAllWindows(tmuxAdapter, dashWindowIndex);
|
|
185
|
+
// 현재 브랜치 조회
|
|
186
|
+
const currentBranch = await gitAdapter.getCurrentBranch(workspaceRoot);
|
|
187
|
+
// 초기 레이아웃 설정: 좌측에 대시보드(20%), 우측에 터미널(80%)
|
|
188
|
+
const initialTerminalPaneId = await tmuxAdapter.splitWindow('v', workspaceRoot, 80);
|
|
189
|
+
// 대시보드 pane으로 포커스 복귀
|
|
190
|
+
await tmuxAdapter.selectPane(dashPaneId);
|
|
191
|
+
// splitWindow triggers async SIGWINCH signals from the resize hook cascade.
|
|
192
|
+
// Wait briefly for them to settle, then sync process.stdout.columns with the
|
|
193
|
+
// actual pane width so Ink calculates line counts correctly on first render.
|
|
194
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
195
|
+
process.stdout.columns = await tmuxAdapter.getPaneWidth(dashPaneId);
|
|
196
|
+
process.stdout.rows = await tmuxAdapter.getPaneHeight(dashPaneId);
|
|
197
|
+
console.clear();
|
|
198
|
+
// 대시보드 UI 실행 (Ink)
|
|
199
|
+
// This never returns - the dashboard keeps running until tmux session is killed
|
|
200
|
+
// When user presses 'q', the dashboard calls detachClient() directly
|
|
201
|
+
const paneHeight = await tmuxAdapter.getPaneHeight(dashPaneId);
|
|
202
|
+
await runInkDashboard({
|
|
203
|
+
workspaceRoot,
|
|
204
|
+
repoName,
|
|
205
|
+
currentBranch,
|
|
206
|
+
initialWindows: windows,
|
|
207
|
+
tmuxAdapter,
|
|
208
|
+
dashWindowIndex,
|
|
209
|
+
paneHeight,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
console.error(chalk.red(`Dashboard error: ${error.message}`));
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ~ 를 홈 디렉토리로 확장
|
|
3
|
+
*/
|
|
4
|
+
export declare function expandTilde(p: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* 경로를 parentDir + leafPrefix로 분리 (자동완성용)
|
|
7
|
+
*/
|
|
8
|
+
export declare function splitPathForCompletion(p: string): {
|
|
9
|
+
parentDir: string;
|
|
10
|
+
leafPrefix: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* 긴 경로를 maxLen 이하로 축약 (앞부분을 ...로 대체)
|
|
14
|
+
*/
|
|
15
|
+
export declare function truncatePath(p: string, maxLen: number): string;
|
|
16
|
+
export type PathStatus = 'valid' | 'creatable' | 'invalid';
|
|
17
|
+
export interface PathValidationResult {
|
|
18
|
+
status: PathStatus;
|
|
19
|
+
isGitRepo: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 경로 유효성 검사
|
|
23
|
+
* - valid: 디렉토리가 존재
|
|
24
|
+
* - creatable: 부모가 존재하고 마지막 한 단계만 새로 생성 가능
|
|
25
|
+
* - invalid: 그 외
|
|
26
|
+
*/
|
|
27
|
+
export declare function validatePath(p: string): Promise<PathValidationResult>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import * as os from 'os';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import { GitAdapter } from '../adapters/GitAdapter.js';
|
|
5
|
+
const gitAdapter = new GitAdapter();
|
|
6
|
+
/**
|
|
7
|
+
* ~ 를 홈 디렉토리로 확장
|
|
8
|
+
*/
|
|
9
|
+
export function expandTilde(p) {
|
|
10
|
+
if (p === '~')
|
|
11
|
+
return os.homedir();
|
|
12
|
+
if (p.startsWith('~/'))
|
|
13
|
+
return path.join(os.homedir(), p.slice(2));
|
|
14
|
+
return p;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 경로를 parentDir + leafPrefix로 분리 (자동완성용)
|
|
18
|
+
*/
|
|
19
|
+
export function splitPathForCompletion(p) {
|
|
20
|
+
const expanded = expandTilde(p);
|
|
21
|
+
if (expanded.endsWith('/')) {
|
|
22
|
+
return { parentDir: expanded, leafPrefix: '' };
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
parentDir: path.dirname(expanded),
|
|
26
|
+
leafPrefix: path.basename(expanded),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 긴 경로를 maxLen 이하로 축약 (앞부분을 ...로 대체)
|
|
31
|
+
*/
|
|
32
|
+
export function truncatePath(p, maxLen) {
|
|
33
|
+
if (p.length <= maxLen)
|
|
34
|
+
return p;
|
|
35
|
+
return '...' + p.slice(p.length - maxLen + 3);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 경로 유효성 검사
|
|
39
|
+
* - valid: 디렉토리가 존재
|
|
40
|
+
* - creatable: 부모가 존재하고 마지막 한 단계만 새로 생성 가능
|
|
41
|
+
* - invalid: 그 외
|
|
42
|
+
*/
|
|
43
|
+
export async function validatePath(p) {
|
|
44
|
+
const expanded = expandTilde(p);
|
|
45
|
+
if (!expanded || !path.isAbsolute(expanded)) {
|
|
46
|
+
return { status: 'invalid', isGitRepo: false };
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const stat = await fs.promises.stat(expanded);
|
|
50
|
+
if (stat.isDirectory()) {
|
|
51
|
+
const isGitRepo = await gitAdapter.isGitRepository(expanded);
|
|
52
|
+
return { status: 'valid', isGitRepo };
|
|
53
|
+
}
|
|
54
|
+
return { status: 'invalid', isGitRepo: false };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// 존재하지 않음 — 부모가 존재하면 creatable
|
|
58
|
+
const parent = path.dirname(expanded);
|
|
59
|
+
try {
|
|
60
|
+
const parentStat = await fs.promises.stat(parent);
|
|
61
|
+
if (parentStat.isDirectory()) {
|
|
62
|
+
return { status: 'creatable', isGitRepo: false };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// 부모도 없음
|
|
67
|
+
}
|
|
68
|
+
return { status: 'invalid', isGitRepo: false };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ThreadInfo } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* worktree 스레드 생성
|
|
4
|
+
*/
|
|
5
|
+
export declare function createThread(workspaceRoot: string, name: string, rootOverride?: string): Promise<ThreadInfo>;
|
|
6
|
+
/**
|
|
7
|
+
* worktree 스레드 삭제
|
|
8
|
+
*/
|
|
9
|
+
export declare function deleteThread(workspaceRoot: string, thread: ThreadInfo): Promise<void>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import { GitAdapter } from '../adapters/GitAdapter.js';
|
|
3
|
+
import { loadConfig, getWorktreeCopyPatterns } from '../config.js';
|
|
4
|
+
import { copyFilesWithPatterns } from '../fileUtils.js';
|
|
5
|
+
const gitAdapter = new GitAdapter();
|
|
6
|
+
/**
|
|
7
|
+
* worktree 스레드 생성
|
|
8
|
+
*/
|
|
9
|
+
export async function createThread(workspaceRoot, name, rootOverride) {
|
|
10
|
+
const baseRoot = rootOverride || workspaceRoot;
|
|
11
|
+
const repoName = path.basename(baseRoot);
|
|
12
|
+
const defaultBasePath = path.join(path.dirname(baseRoot), `${repoName}.worktree`);
|
|
13
|
+
const worktreePath = path.join(defaultBasePath, name);
|
|
14
|
+
// Git worktree 생성
|
|
15
|
+
await gitAdapter.createWorktree(worktreePath, name, baseRoot);
|
|
16
|
+
// 파일 복사
|
|
17
|
+
const configData = await loadConfig(baseRoot);
|
|
18
|
+
const patterns = getWorktreeCopyPatterns(configData);
|
|
19
|
+
if (patterns.length > 0) {
|
|
20
|
+
await copyFilesWithPatterns(baseRoot, worktreePath, patterns);
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
id: worktreePath,
|
|
24
|
+
name,
|
|
25
|
+
path: worktreePath,
|
|
26
|
+
branch: name,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* worktree 스레드 삭제
|
|
31
|
+
*/
|
|
32
|
+
export async function deleteThread(workspaceRoot, thread) {
|
|
33
|
+
await gitAdapter.removeWorktree(thread.path, workspaceRoot, true);
|
|
34
|
+
if (thread.branch) {
|
|
35
|
+
await gitAdapter.deleteBranch(thread.branch, workspaceRoot, true);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { WorktreeInfo } from '@code-squad/core';
|
|
2
|
+
/**
|
|
3
|
+
* tmux window 정보
|
|
4
|
+
*/
|
|
5
|
+
export interface TmuxWindowInfo {
|
|
6
|
+
windowId: string;
|
|
7
|
+
windowIndex: number;
|
|
8
|
+
name: string;
|
|
9
|
+
cwd: string;
|
|
10
|
+
isActive: boolean;
|
|
11
|
+
isGitRepo?: boolean;
|
|
12
|
+
worktreeBranch?: string;
|
|
13
|
+
projectRoot?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 스레드 정보
|
|
17
|
+
*/
|
|
18
|
+
export interface ThreadInfo {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
path: string;
|
|
22
|
+
branch?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* tmux pane 정보
|
|
26
|
+
*/
|
|
27
|
+
export interface PaneInfo {
|
|
28
|
+
id: string;
|
|
29
|
+
index: number;
|
|
30
|
+
worktreePath: string;
|
|
31
|
+
worktreeName: string;
|
|
32
|
+
active: boolean;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 경로 유효성 검사 상태
|
|
36
|
+
*/
|
|
37
|
+
export type PathStatus = 'valid' | 'creatable' | 'invalid';
|
|
38
|
+
export interface PathValidation {
|
|
39
|
+
status: PathStatus;
|
|
40
|
+
isGitRepo: boolean;
|
|
41
|
+
}
|
|
42
|
+
export type { WorktreeInfo };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface VisibleSuggestion {
|
|
2
|
+
name: string;
|
|
3
|
+
isSelected: boolean;
|
|
4
|
+
}
|
|
5
|
+
export interface DirectorySuggestions {
|
|
6
|
+
visibleSuggestions: VisibleSuggestion[];
|
|
7
|
+
hasMore: boolean;
|
|
8
|
+
isOpen: boolean;
|
|
9
|
+
triggerComplete: (inputPath: string) => Promise<string | null>;
|
|
10
|
+
selectNext: () => void;
|
|
11
|
+
selectPrev: () => void;
|
|
12
|
+
acceptSelected: (inputPath: string) => string | null;
|
|
13
|
+
clearSuggestions: () => void;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* 디렉토리 자동완성 훅
|
|
17
|
+
* - Tab: triggerComplete → 매치 1개면 인라인 완성, 여러 개면 제안 목록 표시
|
|
18
|
+
* - Tab (목록 열림): selectNext → 다음 항목
|
|
19
|
+
* - ↑↓ (목록 열림): selectPrev/selectNext
|
|
20
|
+
* - Enter (목록 열림): acceptSelected → 선택 항목 적용
|
|
21
|
+
* - 문자 입력: clearSuggestions → 목록 닫기
|
|
22
|
+
*/
|
|
23
|
+
export declare function useDirectorySuggestions(): DirectorySuggestions;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useMemo } from 'react';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import { splitPathForCompletion } from './pathUtils.js';
|
|
5
|
+
const MAX_VISIBLE = 5;
|
|
6
|
+
/**
|
|
7
|
+
* 디렉토리 자동완성 훅
|
|
8
|
+
* - Tab: triggerComplete → 매치 1개면 인라인 완성, 여러 개면 제안 목록 표시
|
|
9
|
+
* - Tab (목록 열림): selectNext → 다음 항목
|
|
10
|
+
* - ↑↓ (목록 열림): selectPrev/selectNext
|
|
11
|
+
* - Enter (목록 열림): acceptSelected → 선택 항목 적용
|
|
12
|
+
* - 문자 입력: clearSuggestions → 목록 닫기
|
|
13
|
+
*/
|
|
14
|
+
export function useDirectorySuggestions() {
|
|
15
|
+
const [suggestions, setSuggestions] = useState([]);
|
|
16
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
17
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
18
|
+
const requestIdRef = useRef(0);
|
|
19
|
+
const clearSuggestions = useCallback(() => {
|
|
20
|
+
setSuggestions([]);
|
|
21
|
+
setSelectedIndex(0);
|
|
22
|
+
setIsOpen(false);
|
|
23
|
+
}, []);
|
|
24
|
+
const triggerComplete = useCallback(async (inputPath) => {
|
|
25
|
+
const reqId = ++requestIdRef.current;
|
|
26
|
+
const { parentDir, leafPrefix } = splitPathForCompletion(inputPath);
|
|
27
|
+
let entries;
|
|
28
|
+
try {
|
|
29
|
+
const dirEntries = await fs.promises.readdir(parentDir, { withFileTypes: true });
|
|
30
|
+
entries = dirEntries
|
|
31
|
+
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
32
|
+
.filter(e => e.name.toLowerCase().startsWith(leafPrefix.toLowerCase()))
|
|
33
|
+
.map(e => e.name)
|
|
34
|
+
.sort();
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
// Stale check
|
|
40
|
+
if (reqId !== requestIdRef.current)
|
|
41
|
+
return null;
|
|
42
|
+
if (entries.length === 0) {
|
|
43
|
+
clearSuggestions();
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (entries.length === 1) {
|
|
47
|
+
// Single match — inline complete
|
|
48
|
+
clearSuggestions();
|
|
49
|
+
const completed = parentDir.endsWith('/')
|
|
50
|
+
? parentDir + entries[0] + '/'
|
|
51
|
+
: parentDir + '/' + entries[0] + '/';
|
|
52
|
+
return collapseHome(completed);
|
|
53
|
+
}
|
|
54
|
+
// Multiple matches — store all, show scrolling window
|
|
55
|
+
const commonPrefix = findCommonPrefix(entries);
|
|
56
|
+
setSuggestions(entries);
|
|
57
|
+
setSelectedIndex(0);
|
|
58
|
+
setIsOpen(true);
|
|
59
|
+
if (commonPrefix.length > leafPrefix.length) {
|
|
60
|
+
const completed = parentDir.endsWith('/')
|
|
61
|
+
? parentDir + commonPrefix
|
|
62
|
+
: parentDir + '/' + commonPrefix;
|
|
63
|
+
return collapseHome(completed);
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}, [clearSuggestions]);
|
|
67
|
+
const selectNext = useCallback(() => {
|
|
68
|
+
setSelectedIndex(prev => (prev + 1) % Math.max(suggestions.length, 1));
|
|
69
|
+
}, [suggestions.length]);
|
|
70
|
+
const selectPrev = useCallback(() => {
|
|
71
|
+
setSelectedIndex(prev => (prev - 1 + Math.max(suggestions.length, 1)) % Math.max(suggestions.length, 1));
|
|
72
|
+
}, [suggestions.length]);
|
|
73
|
+
const acceptSelected = useCallback((inputPath) => {
|
|
74
|
+
if (!isOpen || suggestions.length === 0)
|
|
75
|
+
return null;
|
|
76
|
+
const selected = suggestions[selectedIndex];
|
|
77
|
+
if (!selected)
|
|
78
|
+
return null;
|
|
79
|
+
const { parentDir } = splitPathForCompletion(inputPath);
|
|
80
|
+
const completed = parentDir.endsWith('/')
|
|
81
|
+
? parentDir + selected + '/'
|
|
82
|
+
: parentDir + '/' + selected + '/';
|
|
83
|
+
clearSuggestions();
|
|
84
|
+
return collapseHome(completed);
|
|
85
|
+
}, [isOpen, suggestions, selectedIndex, clearSuggestions]);
|
|
86
|
+
// Scrolling window: show MAX_VISIBLE items around selectedIndex
|
|
87
|
+
const visibleSuggestions = useMemo(() => {
|
|
88
|
+
if (suggestions.length === 0)
|
|
89
|
+
return [];
|
|
90
|
+
let start;
|
|
91
|
+
if (suggestions.length <= MAX_VISIBLE) {
|
|
92
|
+
start = 0;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
// Center the selected item, clamped to edges
|
|
96
|
+
start = Math.min(Math.max(0, selectedIndex - Math.floor(MAX_VISIBLE / 2)), suggestions.length - MAX_VISIBLE);
|
|
97
|
+
}
|
|
98
|
+
const end = Math.min(start + MAX_VISIBLE, suggestions.length);
|
|
99
|
+
return suggestions.slice(start, end).map(name => ({
|
|
100
|
+
name,
|
|
101
|
+
isSelected: name === suggestions[selectedIndex],
|
|
102
|
+
}));
|
|
103
|
+
}, [suggestions, selectedIndex]);
|
|
104
|
+
const hasMore = suggestions.length > MAX_VISIBLE;
|
|
105
|
+
return {
|
|
106
|
+
visibleSuggestions,
|
|
107
|
+
hasMore,
|
|
108
|
+
isOpen,
|
|
109
|
+
triggerComplete,
|
|
110
|
+
selectNext,
|
|
111
|
+
selectPrev,
|
|
112
|
+
acceptSelected,
|
|
113
|
+
clearSuggestions,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function findCommonPrefix(strs) {
|
|
117
|
+
if (strs.length === 0)
|
|
118
|
+
return '';
|
|
119
|
+
let prefix = strs[0];
|
|
120
|
+
for (let i = 1; i < strs.length; i++) {
|
|
121
|
+
while (!strs[i].toLowerCase().startsWith(prefix.toLowerCase())) {
|
|
122
|
+
prefix = prefix.slice(0, -1);
|
|
123
|
+
if (prefix === '')
|
|
124
|
+
return '';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return prefix;
|
|
128
|
+
}
|
|
129
|
+
function collapseHome(p) {
|
|
130
|
+
const home = os.homedir();
|
|
131
|
+
if (p === home)
|
|
132
|
+
return '~';
|
|
133
|
+
if (p.startsWith(home + '/'))
|
|
134
|
+
return '~' + p.slice(home.length);
|
|
135
|
+
return p;
|
|
136
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PathValidationResult } from './pathUtils.js';
|
|
2
|
+
export interface PathValidationState {
|
|
3
|
+
validation: PathValidationResult | null;
|
|
4
|
+
isGitRepo: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* 경로 유효성 검사 훅 (300ms 디바운스)
|
|
8
|
+
*/
|
|
9
|
+
export declare function usePathValidation(inputPath: string): PathValidationState;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useState, useEffect, useRef } from 'react';
|
|
2
|
+
import { validatePath, expandTilde } from './pathUtils.js';
|
|
3
|
+
/**
|
|
4
|
+
* 경로 유효성 검사 훅 (300ms 디바운스)
|
|
5
|
+
*/
|
|
6
|
+
export function usePathValidation(inputPath) {
|
|
7
|
+
const [validation, setValidation] = useState(null);
|
|
8
|
+
const [isGitRepo, setIsGitRepo] = useState(false);
|
|
9
|
+
const timerRef = useRef(null);
|
|
10
|
+
const requestIdRef = useRef(0);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (timerRef.current)
|
|
13
|
+
clearTimeout(timerRef.current);
|
|
14
|
+
const expanded = expandTilde(inputPath);
|
|
15
|
+
if (!expanded || expanded.length < 2) {
|
|
16
|
+
setValidation(null);
|
|
17
|
+
setIsGitRepo(false);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
timerRef.current = setTimeout(async () => {
|
|
21
|
+
const reqId = ++requestIdRef.current;
|
|
22
|
+
const result = await validatePath(inputPath);
|
|
23
|
+
if (reqId !== requestIdRef.current)
|
|
24
|
+
return;
|
|
25
|
+
setValidation(result);
|
|
26
|
+
setIsGitRepo(result.isGitRepo);
|
|
27
|
+
}, 300);
|
|
28
|
+
return () => {
|
|
29
|
+
if (timerRef.current)
|
|
30
|
+
clearTimeout(timerRef.current);
|
|
31
|
+
};
|
|
32
|
+
}, [inputPath]);
|
|
33
|
+
return { validation, isGitRepo };
|
|
34
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TmuxWindowInfo } from './types.js';
|
|
2
|
+
import type { TmuxAdapter } from './TmuxAdapter.js';
|
|
3
|
+
/**
|
|
4
|
+
* 현재 세션의 모든 window 목록 조회 (대시보드 제외)
|
|
5
|
+
*/
|
|
6
|
+
export declare function loadAllWindows(tmuxAdapter: TmuxAdapter, dashWindowIndex: number): Promise<TmuxWindowInfo[]>;
|
|
7
|
+
/**
|
|
8
|
+
* window 종료
|
|
9
|
+
*/
|
|
10
|
+
export declare function deleteWindowById(tmuxAdapter: TmuxAdapter, windowId: string): Promise<void>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { GitAdapter } from '../adapters/GitAdapter.js';
|
|
2
|
+
const gitAdapter = new GitAdapter();
|
|
3
|
+
/**
|
|
4
|
+
* 현재 세션의 모든 window 목록 조회 (대시보드 제외)
|
|
5
|
+
*/
|
|
6
|
+
export async function loadAllWindows(tmuxAdapter, dashWindowIndex) {
|
|
7
|
+
const rawWindows = await tmuxAdapter.listWindows();
|
|
8
|
+
// 대시보드 window 제외
|
|
9
|
+
const filteredWindows = rawWindows.filter(w => w.index !== dashWindowIndex);
|
|
10
|
+
// Git 정보 추가
|
|
11
|
+
const windowsWithGitInfo = await Promise.all(filteredWindows.map(async (w) => {
|
|
12
|
+
const isGitRepo = await gitAdapter.isGitRepository(w.cwd);
|
|
13
|
+
let worktreeBranch;
|
|
14
|
+
let projectRoot;
|
|
15
|
+
if (isGitRepo) {
|
|
16
|
+
try {
|
|
17
|
+
const context = await gitAdapter.getWorktreeContext(w.cwd);
|
|
18
|
+
worktreeBranch = context.branch ?? undefined;
|
|
19
|
+
projectRoot = context.mainRoot ?? undefined;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// ignore
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
windowId: w.id,
|
|
27
|
+
windowIndex: w.index,
|
|
28
|
+
name: w.name,
|
|
29
|
+
cwd: w.cwd,
|
|
30
|
+
isActive: w.active,
|
|
31
|
+
isGitRepo,
|
|
32
|
+
worktreeBranch,
|
|
33
|
+
projectRoot,
|
|
34
|
+
};
|
|
35
|
+
}));
|
|
36
|
+
return windowsWithGitInfo;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* window 종료
|
|
40
|
+
*/
|
|
41
|
+
export async function deleteWindowById(tmuxAdapter, windowId) {
|
|
42
|
+
await tmuxAdapter.killWindow(windowId);
|
|
43
|
+
}
|