@wong2kim/wmux 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.svg +6 -0
- package/dist/cli/cli/client.js +102 -0
- package/dist/cli/cli/commands/browser.js +137 -0
- package/dist/cli/cli/commands/input.js +80 -0
- package/dist/cli/cli/commands/notify.js +28 -0
- package/dist/cli/cli/commands/pane.js +88 -0
- package/dist/cli/cli/commands/surface.js +98 -0
- package/dist/cli/cli/commands/system.js +98 -0
- package/dist/cli/cli/commands/workspace.js +117 -0
- package/dist/cli/cli/index.js +140 -0
- package/dist/cli/cli/utils.js +47 -0
- package/dist/cli/shared/constants.js +54 -0
- package/dist/cli/shared/rpc.js +33 -0
- package/dist/cli/shared/types.js +79 -0
- package/dist/mcp/mcp/index.js +60 -0
- package/dist/mcp/mcp/wmux-client.js +146 -0
- package/dist/mcp/shared/constants.js +54 -0
- package/dist/mcp/shared/rpc.js +33 -0
- package/dist/mcp/shared/types.js +79 -0
- package/forge.config.ts +61 -0
- package/index.html +12 -0
- package/package.json +84 -0
- package/postcss.config.js +6 -0
- package/src/cli/client.ts +76 -0
- package/src/cli/commands/browser.ts +128 -0
- package/src/cli/commands/input.ts +72 -0
- package/src/cli/commands/notify.ts +29 -0
- package/src/cli/commands/pane.ts +90 -0
- package/src/cli/commands/surface.ts +102 -0
- package/src/cli/commands/system.ts +95 -0
- package/src/cli/commands/workspace.ts +116 -0
- package/src/cli/index.ts +145 -0
- package/src/cli/utils.ts +44 -0
- package/src/main/index.ts +86 -0
- package/src/main/ipc/handlers/clipboard.handler.ts +20 -0
- package/src/main/ipc/handlers/metadata.handler.ts +56 -0
- package/src/main/ipc/handlers/pty.handler.ts +69 -0
- package/src/main/ipc/handlers/session.handler.ts +17 -0
- package/src/main/ipc/handlers/shell.handler.ts +11 -0
- package/src/main/ipc/registerHandlers.ts +31 -0
- package/src/main/mcp/McpRegistrar.ts +156 -0
- package/src/main/metadata/MetadataCollector.ts +58 -0
- package/src/main/notification/ToastManager.ts +32 -0
- package/src/main/pipe/PipeServer.ts +190 -0
- package/src/main/pipe/RpcRouter.ts +46 -0
- package/src/main/pipe/handlers/_bridge.ts +40 -0
- package/src/main/pipe/handlers/browser.rpc.ts +132 -0
- package/src/main/pipe/handlers/input.rpc.ts +120 -0
- package/src/main/pipe/handlers/meta.rpc.ts +59 -0
- package/src/main/pipe/handlers/notify.rpc.ts +53 -0
- package/src/main/pipe/handlers/pane.rpc.ts +39 -0
- package/src/main/pipe/handlers/surface.rpc.ts +43 -0
- package/src/main/pipe/handlers/system.rpc.ts +36 -0
- package/src/main/pipe/handlers/workspace.rpc.ts +52 -0
- package/src/main/pty/AgentDetector.ts +247 -0
- package/src/main/pty/OscParser.ts +81 -0
- package/src/main/pty/PTYBridge.ts +88 -0
- package/src/main/pty/PTYManager.ts +104 -0
- package/src/main/pty/ShellDetector.ts +63 -0
- package/src/main/session/SessionManager.ts +53 -0
- package/src/main/updater/AutoUpdater.ts +132 -0
- package/src/main/window/createWindow.ts +71 -0
- package/src/mcp/README.md +56 -0
- package/src/mcp/index.ts +153 -0
- package/src/mcp/wmux-client.ts +127 -0
- package/src/preload/index.ts +111 -0
- package/src/preload/preload.ts +108 -0
- package/src/renderer/App.tsx +5 -0
- package/src/renderer/components/Browser/BrowserPanel.tsx +219 -0
- package/src/renderer/components/Browser/BrowserToolbar.tsx +253 -0
- package/src/renderer/components/Company/ApprovalDialog.tsx +3 -0
- package/src/renderer/components/Company/CompanyView.tsx +7 -0
- package/src/renderer/components/Company/MessageFeedPanel.tsx +3 -0
- package/src/renderer/components/Layout/AppLayout.tsx +234 -0
- package/src/renderer/components/Notification/NotificationPanel.tsx +129 -0
- package/src/renderer/components/Palette/CommandPalette.tsx +409 -0
- package/src/renderer/components/Palette/PaletteItem.tsx +55 -0
- package/src/renderer/components/Pane/Pane.tsx +122 -0
- package/src/renderer/components/Pane/PaneContainer.tsx +41 -0
- package/src/renderer/components/Pane/SurfaceTabs.tsx +46 -0
- package/src/renderer/components/Settings/SettingsPanel.tsx +886 -0
- package/src/renderer/components/Sidebar/MiniSidebar.tsx +67 -0
- package/src/renderer/components/Sidebar/Sidebar.tsx +84 -0
- package/src/renderer/components/Sidebar/WorkspaceItem.tsx +241 -0
- package/src/renderer/components/StatusBar/StatusBar.tsx +93 -0
- package/src/renderer/components/Terminal/SearchBar.tsx +126 -0
- package/src/renderer/components/Terminal/Terminal.tsx +102 -0
- package/src/renderer/components/Terminal/ViCopyMode.tsx +104 -0
- package/src/renderer/hooks/useKeyboard.ts +310 -0
- package/src/renderer/hooks/useNotificationListener.ts +80 -0
- package/src/renderer/hooks/useNotificationSound.ts +75 -0
- package/src/renderer/hooks/useRpcBridge.ts +451 -0
- package/src/renderer/hooks/useT.ts +11 -0
- package/src/renderer/hooks/useTerminal.ts +349 -0
- package/src/renderer/hooks/useViCopyMode.ts +320 -0
- package/src/renderer/i18n/index.ts +69 -0
- package/src/renderer/i18n/locales/en.ts +157 -0
- package/src/renderer/i18n/locales/ja.ts +155 -0
- package/src/renderer/i18n/locales/ko.ts +155 -0
- package/src/renderer/i18n/locales/zh.ts +155 -0
- package/src/renderer/index.tsx +6 -0
- package/src/renderer/stores/index.ts +19 -0
- package/src/renderer/stores/slices/notificationSlice.ts +56 -0
- package/src/renderer/stores/slices/paneSlice.ts +141 -0
- package/src/renderer/stores/slices/surfaceSlice.ts +122 -0
- package/src/renderer/stores/slices/uiSlice.ts +247 -0
- package/src/renderer/stores/slices/workspaceSlice.ts +120 -0
- package/src/renderer/styles/globals.css +150 -0
- package/src/renderer/themes.ts +99 -0
- package/src/shared/constants.ts +53 -0
- package/src/shared/electron.d.ts +11 -0
- package/src/shared/rpc.ts +71 -0
- package/src/shared/types.ts +176 -0
- package/tailwind.config.js +11 -0
- package/tsconfig.cli.json +24 -0
- package/tsconfig.json +21 -0
- package/tsconfig.mcp.json +25 -0
- package/vite.main.config.ts +14 -0
- package/vite.preload.config.ts +9 -0
- package/vite.renderer.config.ts +6 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useStore } from '../../stores';
|
|
2
|
+
import { useT } from '../../hooks/useT';
|
|
3
|
+
|
|
4
|
+
export default function MiniSidebar() {
|
|
5
|
+
const t = useT();
|
|
6
|
+
const sidebarPosition = useStore((s) => s.sidebarPosition);
|
|
7
|
+
const workspaces = useStore((s) => s.workspaces);
|
|
8
|
+
const activeWorkspaceId = useStore((s) => s.activeWorkspaceId);
|
|
9
|
+
const setActiveWorkspace = useStore((s) => s.setActiveWorkspace);
|
|
10
|
+
const toggleSidebar = useStore((s) => s.toggleSidebar);
|
|
11
|
+
const totalUnread = useStore((s) =>
|
|
12
|
+
s.notifications.filter((n) => !n.read).length,
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div className={`flex flex-col h-full bg-[var(--bg-mantle)] ${sidebarPosition === 'right' ? 'border-l' : 'border-r'} border-[var(--bg-surface)]`} style={{ width: 48 }}>
|
|
17
|
+
{/* Expand button */}
|
|
18
|
+
<button
|
|
19
|
+
className="flex items-center justify-center h-10 text-[var(--text-muted)] hover:text-[var(--text-main)] transition-colors border-b border-[var(--bg-surface)] font-mono text-[11px]"
|
|
20
|
+
onClick={toggleSidebar}
|
|
21
|
+
title={t('sidebar.expandTooltip')}
|
|
22
|
+
>
|
|
23
|
+
›
|
|
24
|
+
</button>
|
|
25
|
+
|
|
26
|
+
{/* Workspace dots */}
|
|
27
|
+
<div className="flex-1 overflow-y-auto py-2 flex flex-col items-center gap-1">
|
|
28
|
+
{workspaces.map((ws, i) => {
|
|
29
|
+
const isActive = ws.id === activeWorkspaceId;
|
|
30
|
+
const initial = ws.name.charAt(0).toUpperCase();
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<button
|
|
34
|
+
key={ws.id}
|
|
35
|
+
className={`w-8 h-8 rounded-md flex items-center justify-center text-xs font-bold font-mono transition-colors ${
|
|
36
|
+
isActive
|
|
37
|
+
? 'bg-[var(--bg-surface)] text-[var(--text-main)]'
|
|
38
|
+
: 'text-[var(--text-muted)] hover:bg-[rgba(var(--bg-surface-rgb),0.5)] hover:text-[var(--text-sub)]'
|
|
39
|
+
}`}
|
|
40
|
+
onClick={() => setActiveWorkspace(ws.id)}
|
|
41
|
+
title={`${ws.name} (Ctrl+${i + 1})`}
|
|
42
|
+
>
|
|
43
|
+
{initial}
|
|
44
|
+
</button>
|
|
45
|
+
);
|
|
46
|
+
})}
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
{/* Status area */}
|
|
50
|
+
<div className="flex flex-col items-center gap-2 py-2 border-t border-[var(--bg-surface)]">
|
|
51
|
+
{/* Unread badge */}
|
|
52
|
+
{totalUnread > 0 && (
|
|
53
|
+
<button
|
|
54
|
+
className="w-8 h-8 rounded-md flex items-center justify-center bg-[rgba(var(--accent-blue-rgb),0.2)] text-[var(--accent-blue)] text-[10px] font-bold"
|
|
55
|
+
onClick={() => useStore.getState().toggleNotificationPanel()}
|
|
56
|
+
title={t('sidebar.unreadCount', { count: totalUnread })}
|
|
57
|
+
>
|
|
58
|
+
{totalUnread > 99 ? '99+' : totalUnread}
|
|
59
|
+
</button>
|
|
60
|
+
)}
|
|
61
|
+
|
|
62
|
+
{/* Workspace count */}
|
|
63
|
+
<span className="text-[9px] font-mono text-[var(--text-muted)]">{workspaces.length}</span>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useStore } from '../../stores';
|
|
2
|
+
import WorkspaceItem from './WorkspaceItem';
|
|
3
|
+
import type { Pane } from '../../../shared/types';
|
|
4
|
+
import { useT } from '../../hooks/useT';
|
|
5
|
+
|
|
6
|
+
// Pane 트리에서 모든 leaf의 PTY를 dispose
|
|
7
|
+
function disposeAllPtys(pane: Pane) {
|
|
8
|
+
if (pane.type === 'leaf') {
|
|
9
|
+
for (const s of pane.surfaces) {
|
|
10
|
+
if (s.ptyId) window.electronAPI.pty.dispose(s.ptyId);
|
|
11
|
+
}
|
|
12
|
+
} else {
|
|
13
|
+
for (const child of pane.children) disposeAllPtys(child);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function Sidebar() {
|
|
18
|
+
const t = useT();
|
|
19
|
+
const sidebarPosition = useStore((s) => s.sidebarPosition);
|
|
20
|
+
const workspaces = useStore((s) => s.workspaces);
|
|
21
|
+
const activeWorkspaceId = useStore((s) => s.activeWorkspaceId);
|
|
22
|
+
const addWorkspace = useStore((s) => s.addWorkspace);
|
|
23
|
+
const removeWorkspace = useStore((s) => s.removeWorkspace);
|
|
24
|
+
const setActiveWorkspace = useStore((s) => s.setActiveWorkspace);
|
|
25
|
+
const renameWorkspace = useStore((s) => s.renameWorkspace);
|
|
26
|
+
const reorderWorkspace = useStore((s) => s.reorderWorkspace);
|
|
27
|
+
const handleCtrlSelect = (wsId: string) => {
|
|
28
|
+
// Just switch to the workspace (don't split)
|
|
29
|
+
setActiveWorkspace(wsId);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const handleClose = (wsId: string) => {
|
|
33
|
+
// 삭제 전 해당 워크스페이스의 모든 PTY 정리
|
|
34
|
+
const ws = workspaces.find((w) => w.id === wsId);
|
|
35
|
+
if (ws) disposeAllPtys(ws.rootPane);
|
|
36
|
+
|
|
37
|
+
removeWorkspace(wsId);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className={`flex flex-col h-full bg-[var(--bg-mantle)] ${sidebarPosition === 'right' ? 'border-l' : 'border-r'} border-[var(--bg-surface)]`} style={{ width: 240 }}>
|
|
42
|
+
{/* Header */}
|
|
43
|
+
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--bg-surface)]">
|
|
44
|
+
<span className="text-sm font-bold text-[var(--text-main)] tracking-widest font-mono">WMUX</span>
|
|
45
|
+
<button
|
|
46
|
+
className="text-[var(--text-subtle)] hover:text-[var(--accent-green)] text-lg leading-none transition-colors"
|
|
47
|
+
onClick={() => addWorkspace()}
|
|
48
|
+
title={t('sidebar.newWorkspaceTooltip')}
|
|
49
|
+
>
|
|
50
|
+
+
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
{/* Workspace list */}
|
|
55
|
+
<div className="flex-1 overflow-y-auto py-2 space-y-0.5">
|
|
56
|
+
{workspaces.map((ws, i) => (
|
|
57
|
+
<WorkspaceItem
|
|
58
|
+
key={ws.id}
|
|
59
|
+
workspace={ws}
|
|
60
|
+
isActive={ws.id === activeWorkspaceId}
|
|
61
|
+
index={i}
|
|
62
|
+
onSelect={() => setActiveWorkspace(ws.id)}
|
|
63
|
+
onCtrlSelect={() => handleCtrlSelect(ws.id)}
|
|
64
|
+
onRename={(name) => renameWorkspace(ws.id, name)}
|
|
65
|
+
onClose={() => handleClose(ws.id)}
|
|
66
|
+
onReorder={reorderWorkspace}
|
|
67
|
+
/>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
{/* Footer */}
|
|
72
|
+
<div className="flex items-center justify-between px-4 py-2 border-t border-[var(--bg-surface)] text-[10px] font-mono text-[var(--text-muted)]">
|
|
73
|
+
<span>{workspaces.length} {t('sidebar.workspaces')}</span>
|
|
74
|
+
<button
|
|
75
|
+
className="text-[var(--text-muted)] hover:text-[var(--text-main)] transition-colors"
|
|
76
|
+
onClick={() => useStore.getState().toggleSidebar()}
|
|
77
|
+
title={t('sidebar.hideTooltip')}
|
|
78
|
+
>
|
|
79
|
+
◀
|
|
80
|
+
</button>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react';
|
|
2
|
+
import type { AgentStatus, Workspace } from '../../../shared/types';
|
|
3
|
+
import { useStore } from '../../stores';
|
|
4
|
+
import { useT } from '../../hooks/useT';
|
|
5
|
+
|
|
6
|
+
interface WorkspaceItemProps {
|
|
7
|
+
workspace: Workspace;
|
|
8
|
+
isActive: boolean;
|
|
9
|
+
index: number;
|
|
10
|
+
onSelect: () => void;
|
|
11
|
+
onCtrlSelect: () => void;
|
|
12
|
+
onRename: (name: string) => void;
|
|
13
|
+
onClose: () => void;
|
|
14
|
+
onReorder: (fromIndex: number, toIndex: number) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Maps AgentStatus to a colored dot indicator character and Tailwind color class.
|
|
18
|
+
const AGENT_STATUS_ICON: Record<AgentStatus, { dot: string; className: string; labelKey: string }> = {
|
|
19
|
+
running: { dot: '●', className: 'text-[var(--accent-blue)]', labelKey: 'workspace.agentRunning' },
|
|
20
|
+
complete: { dot: '●', className: 'text-[var(--accent-green)]', labelKey: 'workspace.agentComplete' },
|
|
21
|
+
error: { dot: '●', className: 'text-[var(--accent-red)]', labelKey: 'workspace.agentError' },
|
|
22
|
+
waiting: { dot: '●', className: 'text-[var(--accent-yellow)]', labelKey: 'workspace.agentWaiting' },
|
|
23
|
+
idle: { dot: '●', className: 'text-[var(--text-muted)]', labelKey: 'workspace.agentIdle' },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function AgentStatusDot({ status, agentName }: { status: AgentStatus; agentName?: string }): React.ReactElement {
|
|
27
|
+
const t = useT();
|
|
28
|
+
const icon = AGENT_STATUS_ICON[status];
|
|
29
|
+
const label = t(icon.labelKey);
|
|
30
|
+
return (
|
|
31
|
+
<span
|
|
32
|
+
className={`text-[8px] leading-none flex-shrink-0 ${icon.className} ${status === 'running' ? 'animate-pulse' : ''}`}
|
|
33
|
+
title={agentName ? `${agentName} — ${label}` : label}
|
|
34
|
+
>
|
|
35
|
+
{icon.dot}
|
|
36
|
+
</span>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function shortenPath(path: string, maxLen = 25): string {
|
|
41
|
+
if (!path || path.length <= maxLen) return path;
|
|
42
|
+
const parts = path.replace(/\\/g, '/').split('/');
|
|
43
|
+
if (parts.length <= 2) return path;
|
|
44
|
+
return `.../${parts.slice(-2).join('/')}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function WorkspaceItem({ workspace, isActive, index, onSelect, onCtrlSelect, onRename, onClose, onReorder }: WorkspaceItemProps) {
|
|
48
|
+
const t = useT();
|
|
49
|
+
const [editing, setEditing] = useState(false);
|
|
50
|
+
const [editName, setEditName] = useState(workspace.name);
|
|
51
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
52
|
+
const [dropIndicator, setDropIndicator] = useState<'above' | 'below' | null>(null);
|
|
53
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
54
|
+
const dragStartTimeRef = useRef<number>(0);
|
|
55
|
+
|
|
56
|
+
const unreadCount = useStore((s) =>
|
|
57
|
+
s.notifications.filter((n) => !n.read && n.workspaceId === workspace.id).length,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const metadata = workspace.metadata;
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (editing) inputRef.current?.focus();
|
|
64
|
+
}, [editing]);
|
|
65
|
+
|
|
66
|
+
const commitRename = () => {
|
|
67
|
+
const trimmed = editName.trim();
|
|
68
|
+
if (trimmed && trimmed !== workspace.name) {
|
|
69
|
+
onRename(trimmed);
|
|
70
|
+
} else {
|
|
71
|
+
setEditName(workspace.name);
|
|
72
|
+
}
|
|
73
|
+
setEditing(false);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const handleDragStart = (e: React.DragEvent<HTMLDivElement>) => {
|
|
77
|
+
dragStartTimeRef.current = Date.now();
|
|
78
|
+
e.dataTransfer.setData('text/plain', String(index));
|
|
79
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
80
|
+
// 약간의 딜레이 후 dragging 상태로 전환 (더블클릭 구분)
|
|
81
|
+
setTimeout(() => setIsDragging(true), 0);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const handleDragEnd = () => {
|
|
85
|
+
setIsDragging(false);
|
|
86
|
+
setDropIndicator(null);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
e.dataTransfer.dropEffect = 'move';
|
|
92
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
93
|
+
const midY = rect.top + rect.height / 2;
|
|
94
|
+
setDropIndicator(e.clientY < midY ? 'above' : 'below');
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
|
98
|
+
// currentTarget 밖으로 나갈 때만 인디케이터 제거
|
|
99
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
|
100
|
+
setDropIndicator(null);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
setDropIndicator(null);
|
|
107
|
+
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
|
|
108
|
+
if (isNaN(fromIndex) || fromIndex === index) return;
|
|
109
|
+
|
|
110
|
+
// 드롭 위치를 아이템 중간 기준으로 결정
|
|
111
|
+
// 위 절반 → 현재 index 앞으로, 아래 절반 → 현재 index 뒤로
|
|
112
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
113
|
+
const midY = rect.top + rect.height / 2;
|
|
114
|
+
const toIndex = e.clientY < midY
|
|
115
|
+
? (fromIndex < index ? index - 1 : index)
|
|
116
|
+
: (fromIndex > index ? index + 1 : index);
|
|
117
|
+
onReorder(fromIndex, toIndex);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
121
|
+
// 드래그 직후 클릭 이벤트 무시 (200ms 이내)
|
|
122
|
+
if (Date.now() - dragStartTimeRef.current < 200) return;
|
|
123
|
+
if (e.ctrlKey) {
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
onCtrlSelect();
|
|
126
|
+
} else {
|
|
127
|
+
onSelect();
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const handleDoubleClick = () => {
|
|
132
|
+
// 드래그 직후 더블클릭 이벤트 무시
|
|
133
|
+
if (Date.now() - dragStartTimeRef.current < 300) return;
|
|
134
|
+
setEditName(workspace.name);
|
|
135
|
+
setEditing(true);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div className="relative mx-2">
|
|
140
|
+
{/* 드롭 인디케이터 - 위 */}
|
|
141
|
+
{dropIndicator === 'above' && (
|
|
142
|
+
<div className="absolute top-0 left-0 right-0 h-0.5 bg-[var(--accent-blue)] rounded-full z-10 -translate-y-px" />
|
|
143
|
+
)}
|
|
144
|
+
|
|
145
|
+
<div
|
|
146
|
+
draggable
|
|
147
|
+
className={`group flex items-start gap-2 px-3 py-1.5 cursor-pointer rounded-md transition-colors select-none ${
|
|
148
|
+
isActive
|
|
149
|
+
? 'bg-[var(--bg-surface)] text-[var(--text-main)]'
|
|
150
|
+
: 'text-[var(--text-subtle)] hover:bg-[rgba(var(--bg-surface-rgb),0.5)] hover:text-[var(--text-sub)]'
|
|
151
|
+
} ${isDragging ? 'opacity-40' : 'opacity-100'}`}
|
|
152
|
+
onClick={handleClick}
|
|
153
|
+
onDoubleClick={handleDoubleClick}
|
|
154
|
+
onDragStart={handleDragStart}
|
|
155
|
+
onDragEnd={handleDragEnd}
|
|
156
|
+
onDragOver={handleDragOver}
|
|
157
|
+
onDragLeave={handleDragLeave}
|
|
158
|
+
onDrop={handleDrop}
|
|
159
|
+
>
|
|
160
|
+
{/* Status indicator */}
|
|
161
|
+
<div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 mt-1.5 ${isActive ? 'bg-[var(--accent-green)]' : 'bg-[var(--text-muted)]'}`} />
|
|
162
|
+
|
|
163
|
+
{/* Name + Metadata */}
|
|
164
|
+
<div className="flex-1 min-w-0">
|
|
165
|
+
{editing ? (
|
|
166
|
+
<input
|
|
167
|
+
ref={inputRef}
|
|
168
|
+
className="w-full bg-[var(--bg-base)] text-[var(--text-main)] text-[11px] font-mono px-1 py-0 rounded border border-[var(--text-muted)] outline-none"
|
|
169
|
+
value={editName}
|
|
170
|
+
onChange={(e) => setEditName(e.target.value)}
|
|
171
|
+
onBlur={commitRename}
|
|
172
|
+
onKeyDown={(e) => {
|
|
173
|
+
if (e.key === 'Enter') commitRename();
|
|
174
|
+
if (e.key === 'Escape') { setEditName(workspace.name); setEditing(false); }
|
|
175
|
+
}}
|
|
176
|
+
onClick={(e) => e.stopPropagation()}
|
|
177
|
+
/>
|
|
178
|
+
) : (
|
|
179
|
+
<>
|
|
180
|
+
<div className="flex items-center gap-1">
|
|
181
|
+
<span className="text-[11px] font-mono truncate">{workspace.name}</span>
|
|
182
|
+
{metadata?.agentStatus && metadata.agentStatus !== 'idle' && (
|
|
183
|
+
<AgentStatusDot status={metadata.agentStatus} agentName={metadata.agentName} />
|
|
184
|
+
)}
|
|
185
|
+
{unreadCount > 0 && (
|
|
186
|
+
<span className="bg-[var(--accent-blue)] text-[var(--bg-base)] text-[9px] font-bold min-w-[16px] h-4 flex items-center justify-center rounded-full px-1 flex-shrink-0">
|
|
187
|
+
{unreadCount}
|
|
188
|
+
</span>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
{metadata && (metadata.gitBranch || metadata.cwd || (metadata.listeningPorts && metadata.listeningPorts.length > 0) || metadata.agentName) && (
|
|
192
|
+
<div className="mt-0.5 space-y-0 text-[10px] text-[var(--text-muted)]">
|
|
193
|
+
{metadata.gitBranch && (
|
|
194
|
+
<div className="truncate" title={metadata.gitBranch}>
|
|
195
|
+
<span className="text-[var(--accent-yellow)]">⎇</span> {metadata.gitBranch}
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
{metadata.cwd && (
|
|
199
|
+
<div className="truncate" title={metadata.cwd}>
|
|
200
|
+
{shortenPath(metadata.cwd)}
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
{metadata.listeningPorts && metadata.listeningPorts.length > 0 && (
|
|
204
|
+
<div className="truncate">
|
|
205
|
+
<span className="text-[var(--accent-green)]">●</span> :{metadata.listeningPorts.slice(0, 3).join(', :')}
|
|
206
|
+
{metadata.listeningPorts.length > 3 && ` +${metadata.listeningPorts.length - 3}`}
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
{metadata.agentName && (
|
|
210
|
+
<div className="truncate" title={`${metadata.agentName}`}>
|
|
211
|
+
<span className="text-[var(--accent-purple)]">⚡</span> {metadata.agentName}
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
</>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
{/* Shortcut hint */}
|
|
221
|
+
<span className="text-[8px] font-mono text-[var(--text-muted)] flex-shrink-0 mt-0.5">
|
|
222
|
+
{index < 9 ? `^${index + 1}` : ''}
|
|
223
|
+
</span>
|
|
224
|
+
|
|
225
|
+
{/* Close button */}
|
|
226
|
+
<button
|
|
227
|
+
className="opacity-0 group-hover:opacity-100 text-[var(--text-subtle)] hover:text-[var(--accent-red)] text-[10px] font-mono flex-shrink-0 mt-0.5 transition-opacity"
|
|
228
|
+
onClick={(e) => { e.stopPropagation(); onClose(); }}
|
|
229
|
+
title={t('workspace.close')}
|
|
230
|
+
>
|
|
231
|
+
✕
|
|
232
|
+
</button>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
{/* 드롭 인디케이터 - 아래 */}
|
|
236
|
+
{dropIndicator === 'below' && (
|
|
237
|
+
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-[var(--accent-blue)] rounded-full z-10 translate-y-px" />
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useStore } from '../../stores';
|
|
3
|
+
import { useT } from '../../hooks/useT';
|
|
4
|
+
|
|
5
|
+
export default function StatusBar() {
|
|
6
|
+
const t = useT();
|
|
7
|
+
const activeWorkspaceId = useStore((s) => s.activeWorkspaceId);
|
|
8
|
+
const workspaces = useStore((s) => s.workspaces);
|
|
9
|
+
const activeWs = workspaces.find((w) => w.id === activeWorkspaceId);
|
|
10
|
+
const unreadCount = useStore((s) => s.notifications.filter((n) => !n.read).length);
|
|
11
|
+
const toggleSettingsPanel = useStore((s) => s.toggleSettingsPanel);
|
|
12
|
+
|
|
13
|
+
// Company 모드 비용 정보
|
|
14
|
+
const sidebarMode = useStore((s) => s.sidebarMode);
|
|
15
|
+
const totalCost = useStore((s) => s.company?.totalCostEstimate ?? 0);
|
|
16
|
+
const sessionStartTime = useStore((s) => s.sessionStartTime);
|
|
17
|
+
|
|
18
|
+
const [time, setTime] = useState(new Date());
|
|
19
|
+
const [memUsage, setMemUsage] = useState('');
|
|
20
|
+
const [sessionMin, setSessionMin] = useState(0);
|
|
21
|
+
|
|
22
|
+
// Update clock every second
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const timer = setInterval(() => {
|
|
25
|
+
setTime(new Date());
|
|
26
|
+
if (sessionStartTime) {
|
|
27
|
+
setSessionMin(Math.floor((Date.now() - sessionStartTime) / 60_000));
|
|
28
|
+
}
|
|
29
|
+
}, 1000);
|
|
30
|
+
return () => clearInterval(timer);
|
|
31
|
+
}, [sessionStartTime]);
|
|
32
|
+
|
|
33
|
+
// Update memory usage every 5 seconds
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
const update = () => {
|
|
36
|
+
const perf = performance as unknown as { memory?: { usedJSHeapSize: number } };
|
|
37
|
+
if (perf.memory) {
|
|
38
|
+
setMemUsage(`${Math.round(perf.memory.usedJSHeapSize / 1024 / 1024)}MB`);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
update();
|
|
42
|
+
const timer = setInterval(update, 5000);
|
|
43
|
+
return () => clearInterval(timer);
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const timeStr = time.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
|
|
47
|
+
const branch = activeWs?.metadata?.gitBranch;
|
|
48
|
+
const isCompanyMode = sidebarMode === 'company';
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="flex items-center justify-between h-6 px-3 bg-[#11111b] border-b border-[var(--bg-surface)] text-[10px] text-[var(--text-muted)] shrink-0 select-none font-mono">
|
|
52
|
+
{/* Left: workspace + branch */}
|
|
53
|
+
<div className="flex items-center gap-3">
|
|
54
|
+
<span className="text-[var(--text-main)] font-medium">{activeWs?.name || 'wmux'}</span>
|
|
55
|
+
{branch && (
|
|
56
|
+
<span>
|
|
57
|
+
<span className="text-[var(--accent-yellow)]">⎇</span> {branch}
|
|
58
|
+
</span>
|
|
59
|
+
)}
|
|
60
|
+
{/* Company 모드 배지 */}
|
|
61
|
+
{isCompanyMode && (
|
|
62
|
+
<span className="text-[8px] font-mono px-1.5 py-px bg-[var(--bg-surface)] text-[var(--accent-blue)] rounded">
|
|
63
|
+
{t('statusBar.company')}
|
|
64
|
+
</span>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{/* Right: status indicators */}
|
|
69
|
+
<div className="flex items-center gap-3">
|
|
70
|
+
{/* Company 모드일 때 비용 표시 */}
|
|
71
|
+
{isCompanyMode && (
|
|
72
|
+
<span className="text-[var(--text-sub2)]" title={t('statusBar.session', { min: sessionMin })}>
|
|
73
|
+
~${totalCost.toFixed(2)}
|
|
74
|
+
</span>
|
|
75
|
+
)}
|
|
76
|
+
{unreadCount > 0 && (
|
|
77
|
+
<span className="text-[var(--accent-blue)]">
|
|
78
|
+
● {unreadCount}
|
|
79
|
+
</span>
|
|
80
|
+
)}
|
|
81
|
+
{memUsage && <span>{memUsage}</span>}
|
|
82
|
+
<span>{timeStr}</span>
|
|
83
|
+
<button
|
|
84
|
+
onClick={toggleSettingsPanel}
|
|
85
|
+
className="text-[var(--text-muted)] hover:text-[var(--text-main)] transition-colors ml-1"
|
|
86
|
+
title={t('statusBar.settingsTooltip')}
|
|
87
|
+
>
|
|
88
|
+
⚙
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useCallback } from 'react';
|
|
2
|
+
import { useT } from '../../hooks/useT';
|
|
3
|
+
|
|
4
|
+
interface SearchBarProps {
|
|
5
|
+
onFindNext: (text: string) => void;
|
|
6
|
+
onFindPrevious: (text: string) => void;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function SearchBar({ onFindNext, onFindPrevious, onClose }: SearchBarProps) {
|
|
11
|
+
const t = useT();
|
|
12
|
+
const [query, setQuery] = useState('');
|
|
13
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
14
|
+
|
|
15
|
+
// 마운트 시 입력 필드에 포커스
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
inputRef.current?.focus();
|
|
18
|
+
}, []);
|
|
19
|
+
|
|
20
|
+
// ESC 키로 닫기
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const handler = (e: KeyboardEvent) => {
|
|
23
|
+
if (e.key === 'Escape') {
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
onClose();
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
window.addEventListener('keydown', handler);
|
|
29
|
+
return () => window.removeEventListener('keydown', handler);
|
|
30
|
+
}, [onClose]);
|
|
31
|
+
|
|
32
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
33
|
+
if (e.key === 'Enter') {
|
|
34
|
+
e.preventDefault();
|
|
35
|
+
if (e.shiftKey) {
|
|
36
|
+
onFindPrevious(query);
|
|
37
|
+
} else {
|
|
38
|
+
onFindNext(query);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}, [query, onFindNext, onFindPrevious]);
|
|
42
|
+
|
|
43
|
+
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
44
|
+
setQuery(e.target.value);
|
|
45
|
+
}, []);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
className="absolute top-0 right-2 z-50 flex items-center gap-1 px-2 py-1.5 rounded-b-md shadow-lg"
|
|
50
|
+
style={{
|
|
51
|
+
background: 'var(--bg-surface)',
|
|
52
|
+
border: '1px solid var(--bg-overlay)',
|
|
53
|
+
borderTop: 'none',
|
|
54
|
+
minWidth: '280px',
|
|
55
|
+
}}
|
|
56
|
+
// 클릭이 Pane의 handleClick까지 버블링되지 않도록 차단
|
|
57
|
+
onClick={(e) => e.stopPropagation()}
|
|
58
|
+
>
|
|
59
|
+
{/* 검색 아이콘 */}
|
|
60
|
+
<svg
|
|
61
|
+
width="13"
|
|
62
|
+
height="13"
|
|
63
|
+
viewBox="0 0 16 16"
|
|
64
|
+
fill="none"
|
|
65
|
+
className="shrink-0 text-[var(--text-subtle)]"
|
|
66
|
+
style={{ color: 'var(--text-subtle)' }}
|
|
67
|
+
>
|
|
68
|
+
<circle cx="6.5" cy="6.5" r="4.5" stroke="currentColor" strokeWidth="1.5" />
|
|
69
|
+
<line x1="10" y1="10" x2="14" y2="14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
70
|
+
</svg>
|
|
71
|
+
|
|
72
|
+
{/* 입력 필드 */}
|
|
73
|
+
<input
|
|
74
|
+
ref={inputRef}
|
|
75
|
+
type="text"
|
|
76
|
+
value={query}
|
|
77
|
+
onChange={handleChange}
|
|
78
|
+
onKeyDown={handleKeyDown}
|
|
79
|
+
placeholder={t('search.placeholder')}
|
|
80
|
+
className="flex-1 bg-transparent outline-none text-xs"
|
|
81
|
+
style={{
|
|
82
|
+
color: 'var(--text-main)',
|
|
83
|
+
caretColor: '#f5e0dc',
|
|
84
|
+
minWidth: 0,
|
|
85
|
+
}}
|
|
86
|
+
spellCheck={false}
|
|
87
|
+
/>
|
|
88
|
+
|
|
89
|
+
{/* 이전 버튼 (Shift+Enter) */}
|
|
90
|
+
<button
|
|
91
|
+
onClick={() => onFindPrevious(query)}
|
|
92
|
+
title={t('search.prevTooltip')}
|
|
93
|
+
className="flex items-center justify-center w-5 h-5 rounded transition-colors hover:bg-[var(--bg-overlay)] text-[var(--text-sub2)] hover:text-[var(--text-main)] shrink-0"
|
|
94
|
+
>
|
|
95
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
|
96
|
+
<path d="M5 8L2 5l3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
97
|
+
<path d="M8 8L5 5l3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
98
|
+
</svg>
|
|
99
|
+
</button>
|
|
100
|
+
|
|
101
|
+
{/* 다음 버튼 (Enter) */}
|
|
102
|
+
<button
|
|
103
|
+
onClick={() => onFindNext(query)}
|
|
104
|
+
title={t('search.nextTooltip')}
|
|
105
|
+
className="flex items-center justify-center w-5 h-5 rounded transition-colors hover:bg-[var(--bg-overlay)] text-[var(--text-sub2)] hover:text-[var(--text-main)] shrink-0"
|
|
106
|
+
>
|
|
107
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
|
108
|
+
<path d="M2 2l3 3-3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
109
|
+
<path d="M5 2l3 3-3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
110
|
+
</svg>
|
|
111
|
+
</button>
|
|
112
|
+
|
|
113
|
+
{/* 닫기 버튼 */}
|
|
114
|
+
<button
|
|
115
|
+
onClick={onClose}
|
|
116
|
+
title={t('search.closeTooltip')}
|
|
117
|
+
className="flex items-center justify-center w-5 h-5 rounded transition-colors hover:bg-[var(--bg-overlay)] text-[var(--text-subtle)] hover:text-[var(--accent-red)] shrink-0"
|
|
118
|
+
>
|
|
119
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
|
120
|
+
<line x1="2" y1="2" x2="8" y2="8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
121
|
+
<line x1="8" y1="2" x2="2" y2="8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
122
|
+
</svg>
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|