@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,409 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
|
2
|
+
import { useStore } from '../../stores';
|
|
3
|
+
import PaletteItem, { type PaletteItemData, type PaletteCategory } from './PaletteItem';
|
|
4
|
+
import { useT } from '../../hooks/useT';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// SVG Icons (inline, no external dependency)
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
function IconSearch() {
|
|
11
|
+
return (
|
|
12
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
13
|
+
<circle cx="6.5" cy="6.5" r="4" stroke="currentColor" strokeWidth="1.4" />
|
|
14
|
+
<line x1="9.85" y1="9.85" x2="13" y2="13" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
|
15
|
+
</svg>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function IconWorkspace() {
|
|
20
|
+
return (
|
|
21
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
22
|
+
<rect x="1" y="1" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.2" />
|
|
23
|
+
<rect x="8" y="1" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.2" />
|
|
24
|
+
<rect x="1" y="8" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.2" />
|
|
25
|
+
<rect x="8" y="8" width="5" height="5" rx="1" stroke="currentColor" strokeWidth="1.2" />
|
|
26
|
+
</svg>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function IconSurface() {
|
|
31
|
+
return (
|
|
32
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
33
|
+
<rect x="1" y="1" width="12" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.2" />
|
|
34
|
+
<line x1="4" y1="12" x2="10" y2="12" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
|
35
|
+
<line x1="7" y1="10" x2="7" y2="12" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
|
36
|
+
</svg>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function IconCommand() {
|
|
41
|
+
return (
|
|
42
|
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
43
|
+
<polyline points="3,5 1,7 3,9" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
|
44
|
+
<polyline points="11,5 13,7 11,9" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
|
45
|
+
<line x1="8.5" y1="3" x2="5.5" y2="11" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
|
46
|
+
</svg>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Fuzzy match helper
|
|
52
|
+
// Scores a string against a query. Returns null if no match, else a score
|
|
53
|
+
// (higher = better). Consecutive character matches are rewarded.
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
function fuzzyScore(str: string, query: string): number | null {
|
|
57
|
+
if (query.length === 0) return 0;
|
|
58
|
+
const s = str.toLowerCase();
|
|
59
|
+
const q = query.toLowerCase();
|
|
60
|
+
let si = 0;
|
|
61
|
+
let qi = 0;
|
|
62
|
+
let score = 0;
|
|
63
|
+
let consecutive = 0;
|
|
64
|
+
let lastMatchIdx = -1;
|
|
65
|
+
|
|
66
|
+
while (si < s.length && qi < q.length) {
|
|
67
|
+
if (s[si] === q[qi]) {
|
|
68
|
+
// Reward consecutive matches and start-of-word matches
|
|
69
|
+
consecutive++;
|
|
70
|
+
if (lastMatchIdx === si - 1) {
|
|
71
|
+
score += 2 + consecutive;
|
|
72
|
+
} else {
|
|
73
|
+
consecutive = 0;
|
|
74
|
+
score += 1;
|
|
75
|
+
}
|
|
76
|
+
// Bonus for matching at word start
|
|
77
|
+
if (si === 0 || s[si - 1] === ' ' || s[si - 1] === '-' || s[si - 1] === '_') {
|
|
78
|
+
score += 3;
|
|
79
|
+
}
|
|
80
|
+
lastMatchIdx = si;
|
|
81
|
+
qi++;
|
|
82
|
+
}
|
|
83
|
+
si++;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return qi === q.length ? score : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// CommandPalette component
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
export default function CommandPalette() {
|
|
94
|
+
const t = useT();
|
|
95
|
+
const visible = useStore((s) => s.commandPaletteVisible);
|
|
96
|
+
const setVisible = useStore((s) => s.setCommandPaletteVisible);
|
|
97
|
+
const workspaces = useStore((s) => s.workspaces);
|
|
98
|
+
const activeWorkspaceId = useStore((s) => s.activeWorkspaceId);
|
|
99
|
+
|
|
100
|
+
const [query, setQuery] = useState('');
|
|
101
|
+
const [activeIdx, setActiveIdx] = useState(0);
|
|
102
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
103
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
104
|
+
|
|
105
|
+
// -------------------------------------------------------------------------
|
|
106
|
+
// Build item list
|
|
107
|
+
// -------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
const buildItems = useCallback((): PaletteItemData[] => {
|
|
110
|
+
const items: PaletteItemData[] = [];
|
|
111
|
+
|
|
112
|
+
// Workspaces
|
|
113
|
+
workspaces.forEach((ws) => {
|
|
114
|
+
items.push({
|
|
115
|
+
id: `ws-${ws.id}`,
|
|
116
|
+
label: ws.name,
|
|
117
|
+
category: 'workspace' as PaletteCategory,
|
|
118
|
+
icon: <IconWorkspace />,
|
|
119
|
+
action: () => {
|
|
120
|
+
useStore.getState().setActiveWorkspace(ws.id);
|
|
121
|
+
setVisible(false);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Surfaces — gather from active workspace leaf panes
|
|
127
|
+
const activeWs = workspaces.find((w) => w.id === activeWorkspaceId);
|
|
128
|
+
if (activeWs) {
|
|
129
|
+
const collectSurfaces = (pane: import('../../../shared/types').Pane) => {
|
|
130
|
+
if (pane.type === 'leaf') {
|
|
131
|
+
pane.surfaces.forEach((surface) => {
|
|
132
|
+
items.push({
|
|
133
|
+
id: `surface-${surface.id}`,
|
|
134
|
+
label: surface.title || 'Terminal',
|
|
135
|
+
category: 'surface' as PaletteCategory,
|
|
136
|
+
icon: <IconSurface />,
|
|
137
|
+
action: () => {
|
|
138
|
+
useStore.getState().setActiveSurface(pane.id, surface.id);
|
|
139
|
+
setVisible(false);
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
} else if (pane.type === 'branch') {
|
|
144
|
+
pane.children.forEach(collectSurfaces);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
collectSurfaces(activeWs.rootPane);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Built-in commands
|
|
151
|
+
const commands: Array<{ label: string; action: () => void }> = [
|
|
152
|
+
{
|
|
153
|
+
label: t('palette.cmd.toggleSidebar'),
|
|
154
|
+
action: () => { useStore.getState().toggleSidebar(); setVisible(false); },
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
label: t('palette.cmd.splitRight'),
|
|
158
|
+
action: () => {
|
|
159
|
+
const state = useStore.getState();
|
|
160
|
+
const ws = state.workspaces.find((w) => w.id === state.activeWorkspaceId);
|
|
161
|
+
if (ws) state.splitPane(ws.activePaneId, 'horizontal');
|
|
162
|
+
setVisible(false);
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
label: t('palette.cmd.splitDown'),
|
|
167
|
+
action: () => {
|
|
168
|
+
const state = useStore.getState();
|
|
169
|
+
const ws = state.workspaces.find((w) => w.id === state.activeWorkspaceId);
|
|
170
|
+
if (ws) state.splitPane(ws.activePaneId, 'vertical');
|
|
171
|
+
setVisible(false);
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
label: t('palette.cmd.newWorkspace'),
|
|
176
|
+
action: () => { useStore.getState().addWorkspace(); setVisible(false); },
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
label: t('palette.cmd.newSurface'),
|
|
180
|
+
action: () => {
|
|
181
|
+
const state = useStore.getState();
|
|
182
|
+
const ws = state.workspaces.find((w) => w.id === state.activeWorkspaceId);
|
|
183
|
+
if (ws) {
|
|
184
|
+
window.electronAPI.pty.create().then((result: { id: string }) => {
|
|
185
|
+
useStore.getState().addSurface(ws.activePaneId, result.id, 'Terminal', '');
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
setVisible(false);
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
label: t('palette.cmd.showNotifications'),
|
|
193
|
+
action: () => { useStore.getState().setNotificationPanelVisible(true); setVisible(false); },
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
label: t('palette.cmd.openBrowser'),
|
|
197
|
+
action: () => {
|
|
198
|
+
const state = useStore.getState();
|
|
199
|
+
const ws = state.workspaces.find((w) => w.id === state.activeWorkspaceId);
|
|
200
|
+
if (ws) {
|
|
201
|
+
state.splitPane(ws.activePaneId, 'horizontal');
|
|
202
|
+
const newState = useStore.getState();
|
|
203
|
+
const newWs = newState.workspaces.find((w) => w.id === newState.activeWorkspaceId);
|
|
204
|
+
if (newWs) {
|
|
205
|
+
newState.addBrowserSurface(newWs.activePaneId);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
setVisible(false);
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
commands.forEach((cmd, i) => {
|
|
214
|
+
items.push({
|
|
215
|
+
id: `cmd-${i}`,
|
|
216
|
+
label: cmd.label,
|
|
217
|
+
category: 'command' as PaletteCategory,
|
|
218
|
+
icon: <IconCommand />,
|
|
219
|
+
action: cmd.action,
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
return items;
|
|
224
|
+
}, [workspaces, activeWorkspaceId, setVisible]);
|
|
225
|
+
|
|
226
|
+
// -------------------------------------------------------------------------
|
|
227
|
+
// Filtered + scored results — useMemo to cache across renders
|
|
228
|
+
// -------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
const results = useMemo((): PaletteItemData[] => {
|
|
231
|
+
const all = buildItems();
|
|
232
|
+
if (!query.trim()) return all;
|
|
233
|
+
|
|
234
|
+
return all
|
|
235
|
+
.map((item) => ({ item, score: fuzzyScore(item.label, query.trim()) }))
|
|
236
|
+
.filter((x) => x.score !== null)
|
|
237
|
+
.sort((a, b) => (b.score as number) - (a.score as number))
|
|
238
|
+
.map((x) => x.item);
|
|
239
|
+
}, [buildItems, query]);
|
|
240
|
+
|
|
241
|
+
// -------------------------------------------------------------------------
|
|
242
|
+
// Reset state when opened
|
|
243
|
+
// -------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
if (visible) {
|
|
247
|
+
setQuery('');
|
|
248
|
+
setActiveIdx(0);
|
|
249
|
+
// Defer focus to ensure the DOM has rendered
|
|
250
|
+
requestAnimationFrame(() => {
|
|
251
|
+
inputRef.current?.focus();
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}, [visible]);
|
|
255
|
+
|
|
256
|
+
// -------------------------------------------------------------------------
|
|
257
|
+
// Keep activeIdx in bounds when results change
|
|
258
|
+
// -------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
useEffect(() => {
|
|
261
|
+
setActiveIdx((prev) => Math.min(prev, Math.max(results.length - 1, 0)));
|
|
262
|
+
}, [results.length]);
|
|
263
|
+
|
|
264
|
+
// -------------------------------------------------------------------------
|
|
265
|
+
// Scroll active item into view
|
|
266
|
+
// -------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
const list = listRef.current;
|
|
270
|
+
if (!list) return;
|
|
271
|
+
const activeEl = list.querySelector<HTMLElement>('[data-active="true"]');
|
|
272
|
+
activeEl?.scrollIntoView({ block: 'nearest' });
|
|
273
|
+
}, [activeIdx]);
|
|
274
|
+
|
|
275
|
+
// -------------------------------------------------------------------------
|
|
276
|
+
// Keyboard navigation inside palette
|
|
277
|
+
// -------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
280
|
+
if (e.key === 'Escape') {
|
|
281
|
+
e.preventDefault();
|
|
282
|
+
setVisible(false);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (e.key === 'ArrowDown') {
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
setActiveIdx((prev) => (prev + 1) % Math.max(results.length, 1));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (e.key === 'ArrowUp') {
|
|
291
|
+
e.preventDefault();
|
|
292
|
+
setActiveIdx((prev) => (prev - 1 + Math.max(results.length, 1)) % Math.max(results.length, 1));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (e.key === 'Enter') {
|
|
296
|
+
e.preventDefault();
|
|
297
|
+
results[activeIdx]?.action();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
if (!visible) return null;
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
// Backdrop
|
|
306
|
+
<div
|
|
307
|
+
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]"
|
|
308
|
+
style={{ backgroundColor: 'rgba(0,0,0,0.55)' }}
|
|
309
|
+
onMouseDown={(e) => {
|
|
310
|
+
// Close when clicking the backdrop, not the palette itself
|
|
311
|
+
if (e.target === e.currentTarget) setVisible(false);
|
|
312
|
+
}}
|
|
313
|
+
>
|
|
314
|
+
{/* Palette container */}
|
|
315
|
+
<div
|
|
316
|
+
className="w-[480px] max-h-[60vh] flex flex-col rounded-xl overflow-hidden shadow-2xl"
|
|
317
|
+
style={{
|
|
318
|
+
backgroundColor: 'var(--bg-base)',
|
|
319
|
+
border: '1px solid var(--bg-surface)',
|
|
320
|
+
boxShadow: '0 25px 60px rgba(0,0,0,0.7)',
|
|
321
|
+
}}
|
|
322
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
323
|
+
>
|
|
324
|
+
{/* Search input row */}
|
|
325
|
+
<div
|
|
326
|
+
className="flex items-center gap-2.5 px-4 py-3"
|
|
327
|
+
style={{ borderBottom: '1px solid var(--bg-surface)' }}
|
|
328
|
+
>
|
|
329
|
+
<span className="shrink-0 text-[var(--text-subtle)]">
|
|
330
|
+
<IconSearch />
|
|
331
|
+
</span>
|
|
332
|
+
<input
|
|
333
|
+
ref={inputRef}
|
|
334
|
+
type="text"
|
|
335
|
+
value={query}
|
|
336
|
+
onChange={(e) => {
|
|
337
|
+
setQuery(e.target.value);
|
|
338
|
+
setActiveIdx(0);
|
|
339
|
+
}}
|
|
340
|
+
onKeyDown={handleKeyDown}
|
|
341
|
+
placeholder={t('palette.placeholder')}
|
|
342
|
+
className="flex-1 bg-transparent text-[var(--text-main)] text-sm placeholder-[var(--text-muted)] outline-none"
|
|
343
|
+
spellCheck={false}
|
|
344
|
+
autoComplete="off"
|
|
345
|
+
/>
|
|
346
|
+
<kbd
|
|
347
|
+
className="shrink-0 text-xs text-[var(--text-muted)] px-1.5 py-0.5 rounded"
|
|
348
|
+
style={{ border: '1px solid var(--bg-overlay)', fontFamily: 'monospace' }}
|
|
349
|
+
>
|
|
350
|
+
ESC
|
|
351
|
+
</kbd>
|
|
352
|
+
</div>
|
|
353
|
+
|
|
354
|
+
{/* Results list */}
|
|
355
|
+
<div ref={listRef} className="overflow-y-auto flex-1">
|
|
356
|
+
{results.length === 0 ? (
|
|
357
|
+
<div className="px-4 py-8 text-center text-sm text-[var(--text-muted)]">
|
|
358
|
+
{t('palette.noResults')} “{query}”
|
|
359
|
+
</div>
|
|
360
|
+
) : (
|
|
361
|
+
results.map((item, idx) => (
|
|
362
|
+
<div key={item.id} data-active={idx === activeIdx ? 'true' : undefined}>
|
|
363
|
+
<PaletteItem
|
|
364
|
+
item={item}
|
|
365
|
+
isActive={idx === activeIdx}
|
|
366
|
+
onClick={item.action}
|
|
367
|
+
/>
|
|
368
|
+
</div>
|
|
369
|
+
))
|
|
370
|
+
)}
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
{/* Footer hint */}
|
|
374
|
+
<div
|
|
375
|
+
className="flex items-center gap-3 px-4 py-2"
|
|
376
|
+
style={{ borderTop: '1px solid var(--bg-surface)', backgroundColor: 'var(--bg-mantle)' }}
|
|
377
|
+
>
|
|
378
|
+
<span className="text-xs text-[var(--text-muted)]">
|
|
379
|
+
<kbd
|
|
380
|
+
className="px-1 py-0.5 rounded mr-0.5"
|
|
381
|
+
style={{ border: '1px solid var(--bg-overlay)', fontFamily: 'monospace' }}
|
|
382
|
+
>
|
|
383
|
+
↑↓
|
|
384
|
+
</kbd>{' '}
|
|
385
|
+
{t('palette.navigate')}
|
|
386
|
+
</span>
|
|
387
|
+
<span className="text-xs text-[var(--text-muted)]">
|
|
388
|
+
<kbd
|
|
389
|
+
className="px-1 py-0.5 rounded mr-0.5"
|
|
390
|
+
style={{ border: '1px solid var(--bg-overlay)', fontFamily: 'monospace' }}
|
|
391
|
+
>
|
|
392
|
+
Enter
|
|
393
|
+
</kbd>{' '}
|
|
394
|
+
{t('palette.select')}
|
|
395
|
+
</span>
|
|
396
|
+
<span className="text-xs text-[var(--text-muted)]">
|
|
397
|
+
<kbd
|
|
398
|
+
className="px-1 py-0.5 rounded mr-0.5"
|
|
399
|
+
style={{ border: '1px solid var(--bg-overlay)', fontFamily: 'monospace' }}
|
|
400
|
+
>
|
|
401
|
+
Esc
|
|
402
|
+
</kbd>{' '}
|
|
403
|
+
{t('palette.close')}
|
|
404
|
+
</span>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
);
|
|
409
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useT } from '../../hooks/useT';
|
|
3
|
+
|
|
4
|
+
export type PaletteCategory = 'workspace' | 'surface' | 'command';
|
|
5
|
+
|
|
6
|
+
export interface PaletteItemData {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
category: PaletteCategory;
|
|
10
|
+
icon: React.ReactNode;
|
|
11
|
+
action: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface PaletteItemProps {
|
|
15
|
+
item: PaletteItemData;
|
|
16
|
+
isActive: boolean;
|
|
17
|
+
onClick: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const categoryColor: Record<PaletteCategory, string> = {
|
|
21
|
+
workspace: 'text-[var(--accent-blue)]',
|
|
22
|
+
surface: 'text-[var(--accent-green)]',
|
|
23
|
+
command: 'text-[var(--accent-purple)]',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default function PaletteItem({ item, isActive, onClick }: PaletteItemProps) {
|
|
27
|
+
const t = useT();
|
|
28
|
+
|
|
29
|
+
const categoryLabel: Record<PaletteCategory, string> = {
|
|
30
|
+
workspace: t('palette.catWorkspace'),
|
|
31
|
+
surface: t('palette.catSurface'),
|
|
32
|
+
command: t('palette.catCommand'),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
onClick={onClick}
|
|
39
|
+
className={[
|
|
40
|
+
'w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors',
|
|
41
|
+
isActive
|
|
42
|
+
? 'bg-[var(--bg-surface)] text-[var(--text-main)]'
|
|
43
|
+
: 'text-[var(--text-sub)] hover:bg-[#2a2a3d] hover:text-[var(--text-main)]',
|
|
44
|
+
].join(' ')}
|
|
45
|
+
>
|
|
46
|
+
<span className="shrink-0 w-4 h-4 flex items-center justify-center text-[var(--text-subtle)]">
|
|
47
|
+
{item.icon}
|
|
48
|
+
</span>
|
|
49
|
+
<span className="flex-1 truncate text-sm">{item.label}</span>
|
|
50
|
+
<span className={`shrink-0 text-xs font-medium ${categoryColor[item.category]}`}>
|
|
51
|
+
{categoryLabel[item.category]}
|
|
52
|
+
</span>
|
|
53
|
+
</button>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import type { PaneLeaf } from '../../../shared/types';
|
|
3
|
+
import { useStore } from '../../stores';
|
|
4
|
+
import { useT } from '../../hooks/useT';
|
|
5
|
+
import TerminalComponent from '../Terminal/Terminal';
|
|
6
|
+
import BrowserPanel from '../Browser/BrowserPanel';
|
|
7
|
+
import SurfaceTabs from './SurfaceTabs';
|
|
8
|
+
|
|
9
|
+
interface PaneProps {
|
|
10
|
+
pane: PaneLeaf;
|
|
11
|
+
isActive: boolean;
|
|
12
|
+
isWorkspaceVisible?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function PaneComponent({ pane, isActive, isWorkspaceVisible = true }: PaneProps) {
|
|
16
|
+
const t = useT();
|
|
17
|
+
const [flashing, setFlashing] = useState(false);
|
|
18
|
+
const setActivePane = useStore((s) => s.setActivePane);
|
|
19
|
+
const setActiveSurface = useStore((s) => s.setActiveSurface);
|
|
20
|
+
const addSurface = useStore((s) => s.addSurface);
|
|
21
|
+
const closeSurface = useStore((s) => s.closeSurface);
|
|
22
|
+
const updateSurfacePtyId = useStore((s) => s.updateSurfacePtyId);
|
|
23
|
+
const markRead = useStore((s) => s.markRead);
|
|
24
|
+
|
|
25
|
+
// count만 가져와 불필요한 배열 참조 안정성 문제 방지
|
|
26
|
+
const unreadCount = useStore((s) =>
|
|
27
|
+
s.notifications.filter(
|
|
28
|
+
(n) => !n.read && pane.surfaces.some((surf) => surf.id === n.surfaceId),
|
|
29
|
+
).length,
|
|
30
|
+
);
|
|
31
|
+
const notificationRingEnabled = useStore((s) => s.notificationRingEnabled);
|
|
32
|
+
const hasUnread = !isActive && unreadCount > 0 && notificationRingEnabled;
|
|
33
|
+
|
|
34
|
+
// Ctrl+Shift+H: flash the active pane
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!isActive) return;
|
|
37
|
+
const handler = () => {
|
|
38
|
+
setFlashing(true);
|
|
39
|
+
setTimeout(() => setFlashing(false), 500);
|
|
40
|
+
};
|
|
41
|
+
document.addEventListener('wmux:flash-pane', handler);
|
|
42
|
+
return () => document.removeEventListener('wmux:flash-pane', handler);
|
|
43
|
+
}, [isActive]);
|
|
44
|
+
|
|
45
|
+
const handleClick = useCallback(() => {
|
|
46
|
+
setActivePane(pane.id);
|
|
47
|
+
// 최신 state에서 직접 읽어 stale closure 방지
|
|
48
|
+
const { notifications } = useStore.getState();
|
|
49
|
+
const surfaceIds = new Set(pane.surfaces.map((s) => s.id));
|
|
50
|
+
for (const n of notifications) {
|
|
51
|
+
if (!n.read && surfaceIds.has(n.surfaceId)) {
|
|
52
|
+
markRead(n.id);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}, [pane.id, pane.surfaces, setActivePane, markRead]);
|
|
56
|
+
|
|
57
|
+
const handleAddSurface = useCallback(() => {
|
|
58
|
+
window.electronAPI.pty.create().then((result: { id: string }) => {
|
|
59
|
+
addSurface(pane.id, result.id, 'Terminal', '');
|
|
60
|
+
});
|
|
61
|
+
}, [pane.id, addSurface]);
|
|
62
|
+
|
|
63
|
+
const closePane = useStore((s) => s.closePane);
|
|
64
|
+
|
|
65
|
+
const handleCloseSurface = useCallback((surfaceId: string) => {
|
|
66
|
+
const surface = pane.surfaces.find((s) => s.id === surfaceId);
|
|
67
|
+
if (surface?.ptyId) {
|
|
68
|
+
window.electronAPI.pty.dispose(surface.ptyId);
|
|
69
|
+
}
|
|
70
|
+
closeSurface(pane.id, surfaceId);
|
|
71
|
+
|
|
72
|
+
// 마지막 Surface가 닫히면 Pane도 자동 제거
|
|
73
|
+
if (pane.surfaces.length <= 1) {
|
|
74
|
+
closePane(pane.id);
|
|
75
|
+
}
|
|
76
|
+
}, [pane.id, pane.surfaces, closeSurface, closePane]);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div
|
|
80
|
+
className={`flex flex-col h-full w-full relative ${
|
|
81
|
+
isActive ? 'ring-1 ring-[rgba(var(--accent-blue-rgb),0.5)]' : ''
|
|
82
|
+
} ${hasUnread ? 'notification-ring' : ''} ${flashing ? 'pane-flash' : ''}`}
|
|
83
|
+
onClick={handleClick}
|
|
84
|
+
>
|
|
85
|
+
<SurfaceTabs
|
|
86
|
+
surfaces={pane.surfaces}
|
|
87
|
+
activeSurfaceId={pane.activeSurfaceId}
|
|
88
|
+
onSelect={(surfaceId) => setActiveSurface(pane.id, surfaceId)}
|
|
89
|
+
onClose={handleCloseSurface}
|
|
90
|
+
onAdd={handleAddSurface}
|
|
91
|
+
/>
|
|
92
|
+
|
|
93
|
+
<div className="flex-1 relative overflow-hidden">
|
|
94
|
+
{pane.surfaces.map((surface) =>
|
|
95
|
+
surface.surfaceType === 'browser' ? (
|
|
96
|
+
<BrowserPanel
|
|
97
|
+
key={surface.id}
|
|
98
|
+
surfaceId={surface.id}
|
|
99
|
+
initialUrl={surface.browserUrl || 'https://google.com'}
|
|
100
|
+
isActive={surface.id === pane.activeSurfaceId}
|
|
101
|
+
onClose={() => handleCloseSurface(surface.id)}
|
|
102
|
+
/>
|
|
103
|
+
) : (
|
|
104
|
+
<TerminalComponent
|
|
105
|
+
key={surface.id}
|
|
106
|
+
ptyId={surface.ptyId || undefined}
|
|
107
|
+
isActive={surface.id === pane.activeSurfaceId}
|
|
108
|
+
isWorkspaceVisible={isWorkspaceVisible}
|
|
109
|
+
onPtyCreated={(ptyId) => updateSurfacePtyId(pane.id, surface.id, ptyId)}
|
|
110
|
+
/>
|
|
111
|
+
)
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{pane.surfaces.length === 0 && (
|
|
115
|
+
<div className="flex items-center justify-center h-full text-[var(--text-muted)] text-sm">
|
|
116
|
+
{t('pane.empty')}
|
|
117
|
+
</div>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Panel, Group, Separator } from 'react-resizable-panels';
|
|
2
|
+
import type { Pane as PaneType } from '../../../shared/types';
|
|
3
|
+
import { useStore } from '../../stores';
|
|
4
|
+
import PaneComponent from './Pane';
|
|
5
|
+
|
|
6
|
+
interface PaneContainerProps {
|
|
7
|
+
pane: PaneType;
|
|
8
|
+
isWorkspaceVisible?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function PaneContainer({ pane, isWorkspaceVisible = true }: PaneContainerProps) {
|
|
12
|
+
const activePaneId = useStore((s) => {
|
|
13
|
+
const ws = s.workspaces.find((w) => w.id === s.activeWorkspaceId);
|
|
14
|
+
return ws?.activePaneId || '';
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
if (pane.type === 'leaf') {
|
|
18
|
+
return <PaneComponent pane={pane} isActive={pane.id === activePaneId} isWorkspaceVisible={isWorkspaceVisible} />;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const orientation = pane.direction === 'horizontal' ? 'horizontal' : 'vertical';
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Group orientation={orientation} className="h-full w-full">
|
|
25
|
+
{pane.children.map((child, i) => (
|
|
26
|
+
<div key={child.id} className="contents">
|
|
27
|
+
{i > 0 && (
|
|
28
|
+
<Separator
|
|
29
|
+
className={`${
|
|
30
|
+
orientation === 'horizontal' ? 'w-1' : 'h-1'
|
|
31
|
+
} bg-[var(--bg-surface)] hover:bg-[var(--accent-blue)] transition-colors`}
|
|
32
|
+
/>
|
|
33
|
+
)}
|
|
34
|
+
<Panel defaultSize={pane.sizes?.[i] ?? 100 / pane.children.length} minSize={10}>
|
|
35
|
+
<PaneContainer pane={child} isWorkspaceVisible={isWorkspaceVisible} />
|
|
36
|
+
</Panel>
|
|
37
|
+
</div>
|
|
38
|
+
))}
|
|
39
|
+
</Group>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Surface } from '../../../shared/types';
|
|
2
|
+
import { useT } from '../../hooks/useT';
|
|
3
|
+
|
|
4
|
+
interface SurfaceTabsProps {
|
|
5
|
+
surfaces: Surface[];
|
|
6
|
+
activeSurfaceId: string;
|
|
7
|
+
onSelect: (surfaceId: string) => void;
|
|
8
|
+
onClose: (surfaceId: string) => void;
|
|
9
|
+
onAdd: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function SurfaceTabs({ surfaces, activeSurfaceId, onSelect, onClose }: SurfaceTabsProps) {
|
|
13
|
+
const t = useT();
|
|
14
|
+
// Hide the tab bar entirely when there is only one surface — no tabs needed.
|
|
15
|
+
// The X close button on a single tab would close the pane itself (handled by Pane.tsx),
|
|
16
|
+
// so it is more intuitive to simply not show the tab strip in the single-tab case.
|
|
17
|
+
if (surfaces.length <= 1) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="flex items-center bg-[var(--bg-mantle)] border-b border-[var(--bg-surface)] h-7 overflow-x-auto">
|
|
23
|
+
{surfaces.map((s) => (
|
|
24
|
+
<div
|
|
25
|
+
key={s.id}
|
|
26
|
+
className={`group flex items-center gap-1 px-3 h-full cursor-pointer text-xs border-r border-[var(--bg-surface)] transition-colors ${
|
|
27
|
+
s.id === activeSurfaceId
|
|
28
|
+
? 'bg-[var(--bg-base)] text-[var(--text-main)]'
|
|
29
|
+
: 'text-[var(--text-subtle)] hover:text-[var(--text-sub)] hover:bg-[rgba(var(--bg-base-rgb),0.5)]'
|
|
30
|
+
}`}
|
|
31
|
+
onClick={() => onSelect(s.id)}
|
|
32
|
+
>
|
|
33
|
+
<span className="truncate max-w-[120px]">{s.title || t('surface.terminal')}</span>
|
|
34
|
+
{/* X close button — always visible, not just on hover */}
|
|
35
|
+
<button
|
|
36
|
+
className="text-[var(--text-subtle)] hover:text-[var(--accent-red)] transition-colors ml-1 leading-none"
|
|
37
|
+
onClick={(e) => { e.stopPropagation(); onClose(s.id); }}
|
|
38
|
+
title={t('surface.closeTab')}
|
|
39
|
+
>
|
|
40
|
+
✕
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|