port-arranger 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.vite/build/index.cjs +2 -0
  2. package/LICENSE +21 -0
  3. package/README.md +192 -0
  4. package/dist/cli/commands/list.d.ts +1 -0
  5. package/dist/cli/commands/list.js +87 -0
  6. package/dist/cli/commands/run.d.ts +7 -0
  7. package/dist/cli/commands/run.js +151 -0
  8. package/dist/cli/commands/stop.d.ts +5 -0
  9. package/dist/cli/commands/stop.js +105 -0
  10. package/dist/cli/commands/ui.d.ts +1 -0
  11. package/dist/cli/commands/ui.js +29 -0
  12. package/dist/cli/core/compose-parser.d.ts +56 -0
  13. package/dist/cli/core/compose-parser.js +184 -0
  14. package/dist/cli/core/compose-parser.test.d.ts +1 -0
  15. package/dist/cli/core/compose-parser.test.js +262 -0
  16. package/dist/cli/core/port-finder.d.ts +2 -0
  17. package/dist/cli/core/port-finder.js +52 -0
  18. package/dist/cli/core/port-finder.test.d.ts +1 -0
  19. package/dist/cli/core/port-finder.test.js +106 -0
  20. package/dist/cli/core/port-injector.d.ts +3 -0
  21. package/dist/cli/core/port-injector.js +191 -0
  22. package/dist/cli/core/port-injector.test.d.ts +1 -0
  23. package/dist/cli/core/port-injector.test.js +264 -0
  24. package/dist/cli/core/process-manager.d.ts +8 -0
  25. package/dist/cli/core/process-manager.js +84 -0
  26. package/dist/cli/core/process-manager.test.d.ts +1 -0
  27. package/dist/cli/core/process-manager.test.js +50 -0
  28. package/dist/cli/core/state.d.ts +10 -0
  29. package/dist/cli/core/state.js +65 -0
  30. package/dist/cli/core/state.test.d.ts +1 -0
  31. package/dist/cli/core/state.test.js +72 -0
  32. package/dist/cli/index.d.ts +2 -0
  33. package/dist/cli/index.js +67 -0
  34. package/dist/gui/main/index.d.ts +1 -0
  35. package/dist/gui/main/index.js +60 -0
  36. package/dist/gui/main/ipc-handlers.d.ts +2 -0
  37. package/dist/gui/main/ipc-handlers.js +66 -0
  38. package/dist/gui/main/state-watcher.d.ts +7 -0
  39. package/dist/gui/main/state-watcher.js +56 -0
  40. package/dist/gui/preload/index.d.ts +1 -0
  41. package/dist/gui/preload/index.js +20 -0
  42. package/dist/gui/renderer/App.d.ts +2 -0
  43. package/dist/gui/renderer/App.js +44 -0
  44. package/dist/gui/renderer/components/ProcessItem.d.ts +10 -0
  45. package/dist/gui/renderer/components/ProcessItem.js +115 -0
  46. package/dist/gui/renderer/components/ProcessList.d.ts +9 -0
  47. package/dist/gui/renderer/components/ProcessList.js +64 -0
  48. package/dist/gui/renderer/components/TitleBar.d.ts +2 -0
  49. package/dist/gui/renderer/components/TitleBar.js +92 -0
  50. package/dist/gui/renderer/hooks/useProcesses.d.ts +10 -0
  51. package/dist/gui/renderer/hooks/useProcesses.js +44 -0
  52. package/dist/gui/renderer/main.d.ts +1 -0
  53. package/dist/gui/renderer/main.js +11 -0
  54. package/dist/shared/types.d.ts +62 -0
  55. package/dist/shared/types.js +1 -0
  56. package/package.json +76 -0
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { join } from 'path';
3
+ import { mkdtemp, rm, readFile } from 'fs/promises';
4
+ import { tmpdir } from 'os';
5
+ import { loadState, saveState, addProcess, removeProcess, getAllProcesses, setStatePath, } from './state.js';
6
+ describe('state', () => {
7
+ let tempDir;
8
+ let testStatePath;
9
+ const mockMapping = {
10
+ port: 3001,
11
+ pid: 12345,
12
+ command: 'next dev',
13
+ originalCommand: 'next dev',
14
+ injectionType: 'env',
15
+ cwd: '/test/project',
16
+ startedAt: new Date().toISOString(),
17
+ status: 'running',
18
+ };
19
+ beforeEach(async () => {
20
+ // 임시 디렉토리 생성
21
+ tempDir = await mkdtemp(join(tmpdir(), 'port-arranger-test-'));
22
+ testStatePath = join(tempDir, 'state.json');
23
+ setStatePath(testStatePath);
24
+ });
25
+ afterEach(async () => {
26
+ // 임시 디렉토리 삭제
27
+ await rm(tempDir, { recursive: true, force: true });
28
+ });
29
+ it('빈 상태에서 시작해야 한다', async () => {
30
+ const state = await loadState();
31
+ expect(state.mappings).toEqual({});
32
+ });
33
+ it('상태를 저장하고 불러올 수 있어야 한다', async () => {
34
+ const state = { mappings: { myproject: mockMapping } };
35
+ await saveState(state);
36
+ const loaded = await loadState();
37
+ expect(loaded.mappings['myproject']).toEqual(mockMapping);
38
+ });
39
+ it('프로세스를 추가할 수 있어야 한다', async () => {
40
+ await addProcess('myproject', mockMapping);
41
+ const state = await loadState();
42
+ expect(state.mappings['myproject']).toBeDefined();
43
+ expect(state.mappings['myproject'].port).toBe(3001);
44
+ });
45
+ it('프로세스를 제거할 수 있어야 한다', async () => {
46
+ await addProcess('myproject', mockMapping);
47
+ await removeProcess('myproject');
48
+ const state = await loadState();
49
+ expect(state.mappings['myproject']).toBeUndefined();
50
+ });
51
+ it('존재하지 않는 프로세스 제거 시 에러가 발생하지 않아야 한다', async () => {
52
+ await expect(removeProcess('nonexistent')).resolves.not.toThrow();
53
+ });
54
+ it('모든 프로세스를 조회할 수 있어야 한다', async () => {
55
+ const mapping2 = {
56
+ ...mockMapping,
57
+ port: 3002,
58
+ pid: 12346,
59
+ };
60
+ await addProcess('project1', mockMapping);
61
+ await addProcess('project2', mapping2);
62
+ const processes = await getAllProcesses();
63
+ expect(Object.keys(processes)).toHaveLength(2);
64
+ expect(processes['project1'].port).toBe(3001);
65
+ expect(processes['project2'].port).toBe(3002);
66
+ });
67
+ it('상태 파일이 유효한 JSON이어야 한다', async () => {
68
+ await addProcess('myproject', mockMapping);
69
+ const content = await readFile(testStatePath, 'utf-8');
70
+ expect(() => JSON.parse(content)).not.toThrow();
71
+ });
72
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { runCommand } from './commands/run.js';
4
+ import { listCommand } from './commands/list.js';
5
+ import { stopCommand } from './commands/stop.js';
6
+ import { uiCommand } from './commands/ui.js';
7
+ const program = new Command();
8
+ program
9
+ .name('pa')
10
+ .description('Run multiple dev servers simultaneously without port conflicts')
11
+ .version('0.0.1');
12
+ program
13
+ .command('run')
14
+ .description('Run a development server')
15
+ .argument('<command>', 'Command to execute')
16
+ .option('-n, --name <name>', 'Project name (default: current directory)')
17
+ .option('-p, --port <port>', 'Preferred port number', parseInt)
18
+ .option('--dry-run', 'Print command without executing')
19
+ .action(async (command, options) => {
20
+ try {
21
+ await runCommand(command, options);
22
+ }
23
+ catch (error) {
24
+ console.error('Error:', error.message);
25
+ process.exit(1);
26
+ }
27
+ });
28
+ program
29
+ .command('list')
30
+ .alias('ls')
31
+ .description('List running processes')
32
+ .action(async () => {
33
+ try {
34
+ await listCommand();
35
+ }
36
+ catch (error) {
37
+ console.error('Error:', error.message);
38
+ process.exit(1);
39
+ }
40
+ });
41
+ program
42
+ .command('stop')
43
+ .description('Stop a process')
44
+ .argument('[name]', 'Name of process to stop')
45
+ .option('-a, --all', 'Stop all processes')
46
+ .action(async (name, options) => {
47
+ try {
48
+ await stopCommand(name, options);
49
+ }
50
+ catch (error) {
51
+ console.error('Error:', error.message);
52
+ process.exit(1);
53
+ }
54
+ });
55
+ program
56
+ .command('ui')
57
+ .description('Launch GUI dashboard')
58
+ .action(async () => {
59
+ try {
60
+ await uiCommand();
61
+ }
62
+ catch (error) {
63
+ console.error('Error:', error.message);
64
+ process.exit(1);
65
+ }
66
+ });
67
+ program.parse();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ import { app, BrowserWindow, ipcMain } from 'electron';
2
+ import path from 'path';
3
+ import { startWatching, stopWatching, addListener, getProcesses } from './state-watcher';
4
+ import { setupIpcHandlers } from './ipc-handlers';
5
+ let mainWindow = null;
6
+ function createWindow() {
7
+ mainWindow = new BrowserWindow({
8
+ width: 400,
9
+ height: 500,
10
+ minWidth: 320,
11
+ minHeight: 300,
12
+ frame: false,
13
+ transparent: false,
14
+ resizable: true,
15
+ webPreferences: {
16
+ preload: path.join(__dirname, 'preload.js'),
17
+ contextIsolation: true,
18
+ nodeIntegration: false,
19
+ },
20
+ });
21
+ // 렌더러 로드
22
+ if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
23
+ mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
24
+ }
25
+ else {
26
+ mainWindow.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`));
27
+ }
28
+ // IPC 핸들러 설정
29
+ setupIpcHandlers(mainWindow);
30
+ // 상태 파일 감시 시작
31
+ startWatching();
32
+ // 프로세스 목록 가져오기 핸들러
33
+ ipcMain.handle('get-processes', async () => {
34
+ return getProcesses();
35
+ });
36
+ // 상태 업데이트 리스너
37
+ const removeListener = addListener((processes) => {
38
+ if (mainWindow && !mainWindow.isDestroyed()) {
39
+ mainWindow.webContents.send('processes-update', processes);
40
+ }
41
+ });
42
+ mainWindow.on('closed', () => {
43
+ removeListener();
44
+ mainWindow = null;
45
+ });
46
+ }
47
+ app.whenReady().then(() => {
48
+ createWindow();
49
+ app.on('activate', () => {
50
+ if (BrowserWindow.getAllWindows().length === 0) {
51
+ createWindow();
52
+ }
53
+ });
54
+ });
55
+ app.on('window-all-closed', () => {
56
+ stopWatching();
57
+ if (process.platform !== 'darwin') {
58
+ app.quit();
59
+ }
60
+ });
@@ -0,0 +1,2 @@
1
+ import { BrowserWindow } from 'electron';
2
+ export declare function setupIpcHandlers(mainWindow: BrowserWindow): void;
@@ -0,0 +1,66 @@
1
+ import { ipcMain, shell } from 'electron';
2
+ import { readFile, writeFile } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { homedir } from 'os';
5
+ import treeKill from 'tree-kill';
6
+ const STATE_PATH = join(homedir(), '.port-arranger', 'state.json');
7
+ async function loadState() {
8
+ try {
9
+ const content = await readFile(STATE_PATH, 'utf-8');
10
+ return JSON.parse(content);
11
+ }
12
+ catch {
13
+ return { mappings: {} };
14
+ }
15
+ }
16
+ async function saveState(state) {
17
+ await writeFile(STATE_PATH, JSON.stringify(state, null, 2), 'utf-8');
18
+ }
19
+ function killProcess(pid) {
20
+ return new Promise((resolve, reject) => {
21
+ treeKill(pid, 'SIGTERM', (err) => {
22
+ if (err) {
23
+ reject(err);
24
+ }
25
+ else {
26
+ resolve();
27
+ }
28
+ });
29
+ });
30
+ }
31
+ export function setupIpcHandlers(mainWindow) {
32
+ ipcMain.handle('stop-process', async (_event, name) => {
33
+ const state = await loadState();
34
+ const mapping = state.mappings[name];
35
+ if (!mapping) {
36
+ throw new Error(`프로세스를 찾을 수 없습니다: ${name}`);
37
+ }
38
+ await killProcess(mapping.pid);
39
+ const { [name]: _, ...rest } = state.mappings;
40
+ await saveState({ ...state, mappings: rest });
41
+ });
42
+ ipcMain.handle('restart-process', async (_event, name) => {
43
+ const state = await loadState();
44
+ const mapping = state.mappings[name];
45
+ if (!mapping) {
46
+ throw new Error(`프로세스를 찾을 수 없습니다: ${name}`);
47
+ }
48
+ // 프로세스 종료 후 재시작은 CLI를 통해 해야 함
49
+ // GUI에서는 단순히 중지만 수행
50
+ await killProcess(mapping.pid);
51
+ const { [name]: _, ...rest } = state.mappings;
52
+ await saveState({ ...state, mappings: rest });
53
+ });
54
+ ipcMain.handle('open-browser', async (_event, port) => {
55
+ await shell.openExternal(`http://localhost:${port}`);
56
+ });
57
+ ipcMain.handle('set-always-on-top', async (_event, value) => {
58
+ mainWindow.setAlwaysOnTop(value);
59
+ });
60
+ ipcMain.on('minimize-window', () => {
61
+ mainWindow.minimize();
62
+ });
63
+ ipcMain.on('close-window', () => {
64
+ mainWindow.close();
65
+ });
66
+ }
@@ -0,0 +1,7 @@
1
+ import type { ProcessMapping } from '../../shared/types';
2
+ type ProcessesCallback = (processes: Record<string, ProcessMapping>) => void;
3
+ export declare function startWatching(): void;
4
+ export declare function stopWatching(): void;
5
+ export declare function addListener(callback: ProcessesCallback): () => void;
6
+ export declare function getProcesses(): Promise<Record<string, ProcessMapping>>;
7
+ export {};
@@ -0,0 +1,56 @@
1
+ import { watch } from 'chokidar';
2
+ import { readFile } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { homedir } from 'os';
5
+ const STATE_PATH = join(homedir(), '.port-arranger', 'state.json');
6
+ let watcher = null;
7
+ const listeners = new Set();
8
+ async function loadState() {
9
+ try {
10
+ const content = await readFile(STATE_PATH, 'utf-8');
11
+ const state = JSON.parse(content);
12
+ return state.mappings;
13
+ }
14
+ catch {
15
+ return {};
16
+ }
17
+ }
18
+ async function notifyListeners() {
19
+ const processes = await loadState();
20
+ listeners.forEach((callback) => callback(processes));
21
+ }
22
+ export function startWatching() {
23
+ if (watcher)
24
+ return;
25
+ watcher = watch(STATE_PATH, {
26
+ persistent: true,
27
+ ignoreInitial: false,
28
+ awaitWriteFinish: {
29
+ stabilityThreshold: 100,
30
+ pollInterval: 50,
31
+ },
32
+ });
33
+ watcher.on('add', () => notifyListeners());
34
+ watcher.on('change', () => notifyListeners());
35
+ watcher.on('unlink', () => {
36
+ listeners.forEach((callback) => callback({}));
37
+ });
38
+ }
39
+ export function stopWatching() {
40
+ if (watcher) {
41
+ watcher.close();
42
+ watcher = null;
43
+ }
44
+ listeners.clear();
45
+ }
46
+ export function addListener(callback) {
47
+ listeners.add(callback);
48
+ // 즉시 현재 상태 전달
49
+ loadState().then(callback);
50
+ return () => {
51
+ listeners.delete(callback);
52
+ };
53
+ }
54
+ export async function getProcesses() {
55
+ return loadState();
56
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,20 @@
1
+ import { contextBridge, ipcRenderer } from 'electron';
2
+ const api = {
3
+ getProcesses: () => ipcRenderer.invoke('get-processes'),
4
+ onProcessesUpdate: (callback) => {
5
+ const handler = (_event, processes) => {
6
+ callback(processes);
7
+ };
8
+ ipcRenderer.on('processes-update', handler);
9
+ return () => {
10
+ ipcRenderer.removeListener('processes-update', handler);
11
+ };
12
+ },
13
+ stopProcess: (name) => ipcRenderer.invoke('stop-process', name),
14
+ restartProcess: (name) => ipcRenderer.invoke('restart-process', name),
15
+ openBrowser: (port) => ipcRenderer.invoke('open-browser', port),
16
+ setAlwaysOnTop: (value) => ipcRenderer.invoke('set-always-on-top', value),
17
+ minimizeWindow: () => ipcRenderer.send('minimize-window'),
18
+ closeWindow: () => ipcRenderer.send('close-window'),
19
+ };
20
+ contextBridge.exposeInMainWorld('electronAPI', api);
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export default function App(): React.ReactElement;
@@ -0,0 +1,44 @@
1
+ import React from 'react';
2
+ import { TitleBar } from './components/TitleBar';
3
+ import { ProcessList } from './components/ProcessList';
4
+ import { useProcesses } from './hooks/useProcesses';
5
+ const styles = {
6
+ app: {
7
+ display: 'flex',
8
+ flexDirection: 'column',
9
+ height: '100vh',
10
+ backgroundColor: 'var(--bg-primary)',
11
+ },
12
+ content: {
13
+ flex: 1,
14
+ display: 'flex',
15
+ flexDirection: 'column',
16
+ overflow: 'hidden',
17
+ },
18
+ loading: {
19
+ flex: 1,
20
+ display: 'flex',
21
+ alignItems: 'center',
22
+ justifyContent: 'center',
23
+ color: 'var(--text-secondary)',
24
+ },
25
+ error: {
26
+ padding: '12px',
27
+ margin: '12px',
28
+ backgroundColor: 'rgba(233, 69, 96, 0.1)',
29
+ border: '1px solid var(--accent)',
30
+ borderRadius: '8px',
31
+ color: 'var(--accent)',
32
+ fontSize: '13px',
33
+ },
34
+ };
35
+ export default function App() {
36
+ const { processes, loading, error, stopProcess, openBrowser } = useProcesses();
37
+ return (<div style={styles.app}>
38
+ <TitleBar />
39
+ <div style={styles.content}>
40
+ {error && <div style={styles.error}>{error}</div>}
41
+ {loading ? (<div style={styles.loading}>로딩 중...</div>) : (<ProcessList processes={processes} onStop={stopProcess} onOpenBrowser={openBrowser}/>)}
42
+ </div>
43
+ </div>);
44
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import type { ProcessMapping } from '../../../shared/types';
3
+ interface ProcessItemProps {
4
+ name: string;
5
+ process: ProcessMapping;
6
+ onStop: (name: string) => void;
7
+ onOpenBrowser: (port: number) => void;
8
+ }
9
+ export declare function ProcessItem({ name, process, onStop, onOpenBrowser, }: ProcessItemProps): React.ReactElement;
10
+ export {};
@@ -0,0 +1,115 @@
1
+ import React from 'react';
2
+ const styles = {
3
+ item: {
4
+ display: 'flex',
5
+ flexDirection: 'column',
6
+ padding: '12px',
7
+ backgroundColor: 'var(--bg-item)',
8
+ borderRadius: '8px',
9
+ gap: '8px',
10
+ },
11
+ header: {
12
+ display: 'flex',
13
+ justifyContent: 'space-between',
14
+ alignItems: 'center',
15
+ },
16
+ name: {
17
+ fontSize: '14px',
18
+ fontWeight: 600,
19
+ color: 'var(--text-primary)',
20
+ },
21
+ status: {
22
+ display: 'flex',
23
+ alignItems: 'center',
24
+ gap: '6px',
25
+ fontSize: '12px',
26
+ },
27
+ statusDot: {
28
+ width: '8px',
29
+ height: '8px',
30
+ borderRadius: '50%',
31
+ backgroundColor: 'var(--success)',
32
+ },
33
+ info: {
34
+ display: 'flex',
35
+ gap: '16px',
36
+ fontSize: '12px',
37
+ color: 'var(--text-secondary)',
38
+ },
39
+ port: {
40
+ cursor: 'pointer',
41
+ color: 'var(--accent)',
42
+ textDecoration: 'underline',
43
+ background: 'none',
44
+ border: 'none',
45
+ fontSize: '12px',
46
+ padding: 0,
47
+ },
48
+ command: {
49
+ fontSize: '11px',
50
+ color: 'var(--text-secondary)',
51
+ fontFamily: 'monospace',
52
+ overflow: 'hidden',
53
+ textOverflow: 'ellipsis',
54
+ whiteSpace: 'nowrap',
55
+ },
56
+ actions: {
57
+ display: 'flex',
58
+ gap: '8px',
59
+ marginTop: '4px',
60
+ },
61
+ button: {
62
+ padding: '6px 12px',
63
+ borderRadius: '4px',
64
+ fontSize: '12px',
65
+ fontWeight: 500,
66
+ transition: 'all 0.15s',
67
+ },
68
+ stopButton: {
69
+ backgroundColor: 'var(--accent)',
70
+ color: 'white',
71
+ },
72
+ };
73
+ export function ProcessItem({ name, process, onStop, onOpenBrowser, }) {
74
+ const formatTime = (isoString) => {
75
+ const date = new Date(isoString);
76
+ return date.toLocaleTimeString('ko-KR', {
77
+ hour: '2-digit',
78
+ minute: '2-digit',
79
+ });
80
+ };
81
+ return (<div style={styles.item}>
82
+ <div style={styles.header}>
83
+ <span style={styles.name}>{name}</span>
84
+ <div style={styles.status}>
85
+ <span style={styles.statusDot}/>
86
+ <span>실행 중</span>
87
+ </div>
88
+ </div>
89
+
90
+ <div style={styles.info}>
91
+ <span>
92
+ 포트:{' '}
93
+ <button style={styles.port} onClick={() => onOpenBrowser(process.port)} title="브라우저에서 열기">
94
+ {process.port}
95
+ </button>
96
+ </span>
97
+ <span>PID: {process.pid}</span>
98
+ <span>시작: {formatTime(process.startedAt)}</span>
99
+ </div>
100
+
101
+ <div style={styles.command} title={process.originalCommand}>
102
+ {process.originalCommand}
103
+ </div>
104
+
105
+ <div style={styles.actions}>
106
+ <button style={{ ...styles.button, ...styles.stopButton }} onClick={() => onStop(name)} onMouseEnter={(e) => {
107
+ e.currentTarget.style.backgroundColor = 'var(--accent-hover)';
108
+ }} onMouseLeave={(e) => {
109
+ e.currentTarget.style.backgroundColor = 'var(--accent)';
110
+ }}>
111
+ 중지
112
+ </button>
113
+ </div>
114
+ </div>);
115
+ }
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ import type { ProcessMapping } from '../../../shared/types';
3
+ interface ProcessListProps {
4
+ processes: Record<string, ProcessMapping>;
5
+ onStop: (name: string) => void;
6
+ onOpenBrowser: (port: number) => void;
7
+ }
8
+ export declare function ProcessList({ processes, onStop, onOpenBrowser, }: ProcessListProps): React.ReactElement;
9
+ export {};
@@ -0,0 +1,64 @@
1
+ import React from 'react';
2
+ import { ProcessItem } from './ProcessItem';
3
+ const styles = {
4
+ container: {
5
+ flex: 1,
6
+ overflow: 'auto',
7
+ padding: '12px',
8
+ display: 'flex',
9
+ flexDirection: 'column',
10
+ gap: '8px',
11
+ },
12
+ empty: {
13
+ flex: 1,
14
+ display: 'flex',
15
+ flexDirection: 'column',
16
+ alignItems: 'center',
17
+ justifyContent: 'center',
18
+ color: 'var(--text-secondary)',
19
+ gap: '12px',
20
+ padding: '40px',
21
+ textAlign: 'center',
22
+ },
23
+ emptyTitle: {
24
+ fontSize: '16px',
25
+ fontWeight: 600,
26
+ color: 'var(--text-primary)',
27
+ },
28
+ emptyHint: {
29
+ fontSize: '13px',
30
+ lineHeight: 1.5,
31
+ },
32
+ code: {
33
+ backgroundColor: 'var(--bg-item)',
34
+ padding: '4px 8px',
35
+ borderRadius: '4px',
36
+ fontFamily: 'monospace',
37
+ fontSize: '12px',
38
+ },
39
+ count: {
40
+ padding: '4px 12px',
41
+ fontSize: '12px',
42
+ color: 'var(--text-secondary)',
43
+ borderBottom: '1px solid var(--border)',
44
+ },
45
+ };
46
+ export function ProcessList({ processes, onStop, onOpenBrowser, }) {
47
+ const entries = Object.entries(processes);
48
+ if (entries.length === 0) {
49
+ return (<div style={styles.empty}>
50
+ <span style={styles.emptyTitle}>실행 중인 프로세스 없음</span>
51
+ <span style={styles.emptyHint}>
52
+ 터미널에서 <code style={styles.code}>pa run</code> 명령으로
53
+ <br />
54
+ 개발 서버를 실행하세요
55
+ </span>
56
+ </div>);
57
+ }
58
+ return (<>
59
+ <div style={styles.count}>{entries.length}개의 프로세스</div>
60
+ <div style={styles.container}>
61
+ {entries.map(([name, process]) => (<ProcessItem key={name} name={name} process={process} onStop={onStop} onOpenBrowser={onOpenBrowser}/>))}
62
+ </div>
63
+ </>);
64
+ }
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare function TitleBar(): React.ReactElement;