tmuxes 0.1.9 → 0.1.10
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/agentHooks.js +91 -0
- package/dist/agentHooks.js.map +1 -0
- package/dist/agentOutput.js +30 -0
- package/dist/agentOutput.js.map +1 -0
- package/dist/agentState.js +45 -0
- package/dist/agentState.js.map +1 -0
- package/dist/config.js +32 -0
- package/dist/config.js.map +1 -0
- package/dist/exe.js +37 -0
- package/dist/exe.js.map +1 -0
- package/dist/exec.js +43 -0
- package/dist/exec.js.map +1 -0
- package/dist/files.js +308 -0
- package/dist/files.js.map +1 -0
- package/dist/foldersStore.js +103 -0
- package/dist/foldersStore.js.map +1 -0
- package/dist/index.js +117 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.js +16 -0
- package/dist/logger.js.map +1 -0
- package/{server/src/monitor.ts → dist/monitor.js} +9 -10
- package/dist/monitor.js.map +1 -0
- package/dist/openBrowser.js +31 -0
- package/dist/openBrowser.js.map +1 -0
- package/{server/src/platform.ts → dist/platform.js} +5 -4
- package/dist/platform.js.map +1 -0
- package/dist/rest/router.js +198 -0
- package/dist/rest/router.js.map +1 -0
- package/dist/targetCommand.js +60 -0
- package/dist/targetCommand.js.map +1 -0
- package/dist/targets.js +131 -0
- package/dist/targets.js.map +1 -0
- package/dist/tmux/builder.js +174 -0
- package/dist/tmux/builder.js.map +1 -0
- package/dist/tmux/formats.js +61 -0
- package/dist/tmux/formats.js.map +1 -0
- package/dist/tmux/sessions.js +157 -0
- package/dist/tmux/sessions.js.map +1 -0
- package/dist/validate.js +65 -0
- package/dist/validate.js.map +1 -0
- package/dist/windowsSsh.js +209 -0
- package/dist/windowsSsh.js.map +1 -0
- package/dist/winshell/manager.js +267 -0
- package/dist/winshell/manager.js.map +1 -0
- package/dist/ws/protocol.js +4 -0
- package/dist/ws/protocol.js.map +1 -0
- package/dist/ws/sshState.js +35 -0
- package/dist/ws/sshState.js.map +1 -0
- package/dist/ws/terminalSession.js +204 -0
- package/dist/ws/terminalSession.js.map +1 -0
- package/dist/ws/wsServer.js +151 -0
- package/dist/ws/wsServer.js.map +1 -0
- package/dist/wsl.js +35 -0
- package/dist/wsl.js.map +1 -0
- package/package.json +49 -16
- package/public/assets/index-D_X5SnGx.css +1 -0
- package/public/assets/index-Dl69CPyt.js +44 -0
- package/{client → public}/index.html +3 -2
- package/.node-version +0 -1
- package/.nvmrc +0 -1
- package/.tmp-npm-cache/_cacache/content-v2/sha512/43/27/5e000b8b9c56a6ccc66f709485499f4304e2cb1982582ba571321c07b3ef56fcabd2c671898cc8003365a0485b6fd8e73e7b17b073cec0f7d1628c1a99df +0 -0
- package/.tmp-npm-cache/_cacache/content-v2/sha512/51/cf/4301295d74559ed494bae160d54d8741077f89faebb311882ac065019246951e7b53f3dcb913793c42b331e14c7070c4810c3cdc27a427d103a7db4614e0 +0 -0
- package/.tmp-npm-cache/_cacache/content-v2/sha512/c3/4d/d68a454a916e74c2617f586fbf770981b33811d667c2547eb0e9fc21938f4ee7e98f1ceee4bde8ad8815b5f6efe21b60eee798837d68f51a3340d7e5bb7a +0 -0
- package/.tmp-npm-cache/_cacache/content-v2/sha512/fe/40/2abfbefc96299e8bf714aa91d62607190ae299e102cf5933db2e2904640d65d25d67dbbb6fa2ddc92a17f00b9dbfdf2e37487f67d96ec36c64a285b59a7d +0 -0
- package/.tmp-npm-cache/_cacache/index-v5/27/fe/81a3de6ce7ae3d1e41a3421de20c5629998c4ee5d0ffe2037630f03b03b2 +0 -4
- package/.tmp-npm-cache/_cacache/index-v5/65/22/dd66711f62681fce09aabb2357a2907b4a0c778ac5227c4baf9603fd86e8 +0 -4
- package/.tmp-npm-cache/_update-notifier-last-checked +0 -0
- package/AGENTS.md +0 -15
- package/CLAUDE.md +0 -3
- package/README.en.md +0 -304
- package/SECURITY.md +0 -31
- package/client/package.json +0 -29
- package/client/src/App.tsx +0 -123
- package/client/src/activity.ts +0 -5
- package/client/src/api.ts +0 -130
- package/client/src/attention.tsx +0 -157
- package/client/src/components/FileExplorer.tsx +0 -156
- package/client/src/components/FileViewer.tsx +0 -194
- package/client/src/components/SessionRow.tsx +0 -108
- package/client/src/components/SessionTree.tsx +0 -197
- package/client/src/components/SettingsButton.tsx +0 -122
- package/client/src/components/Sidebar.tsx +0 -96
- package/client/src/components/StatusBanner.tsx +0 -31
- package/client/src/components/TargetGroup.tsx +0 -275
- package/client/src/components/TerminalPanel.tsx +0 -192
- package/client/src/folders.ts +0 -245
- package/client/src/hooks/useTerminal.ts +0 -67
- package/client/src/hooks/useTmuxSocket.ts +0 -65
- package/client/src/i18n.ts +0 -213
- package/client/src/main.tsx +0 -17
- package/client/src/settings.tsx +0 -87
- package/client/src/styles.css +0 -723
- package/client/src/types.ts +0 -93
- package/client/src/util.ts +0 -65
- package/client/tsconfig.json +0 -13
- package/client/vite.config.ts +0 -15
- package/fig/fig1.png +0 -0
- package/scripts/prepack.mjs +0 -35
- package/server/package.json +0 -61
- package/server/src/agentHooks.ts +0 -120
- package/server/src/agentOutput.ts +0 -36
- package/server/src/agentState.ts +0 -70
- package/server/src/config.ts +0 -31
- package/server/src/exe.ts +0 -34
- package/server/src/exec.ts +0 -61
- package/server/src/files.ts +0 -330
- package/server/src/foldersStore.ts +0 -114
- package/server/src/index.ts +0 -114
- package/server/src/logger.ts +0 -16
- package/server/src/openBrowser.ts +0 -28
- package/server/src/rest/router.ts +0 -290
- package/server/src/targetCommand.ts +0 -79
- package/server/src/targets.ts +0 -152
- package/server/src/tmux/builder.ts +0 -198
- package/server/src/tmux/formats.ts +0 -95
- package/server/src/tmux/sessions.ts +0 -204
- package/server/src/validate.ts +0 -79
- package/server/src/windowsSsh.ts +0 -239
- package/server/src/winshell/manager.ts +0 -296
- package/server/src/ws/protocol.ts +0 -15
- package/server/src/ws/sshState.ts +0 -36
- package/server/src/ws/terminalSession.ts +0 -207
- package/server/src/ws/wsServer.ts +0 -153
- package/server/src/wsl.ts +0 -38
- package/server/test/agentHooks.test.ts +0 -66
- package/server/test/agentOutput.test.ts +0 -26
- package/server/test/agentState.test.ts +0 -24
- package/server/test/builder.test.ts +0 -162
- package/server/test/files.test.ts +0 -81
- package/server/test/formats.test.ts +0 -123
- package/server/test/monitor.test.ts +0 -25
- package/server/test/validate.test.ts +0 -71
- package/server/test/wsl.test.ts +0 -18
- package/server/tsconfig.json +0 -9
- package/server/vitest.config.ts +0 -12
- package/start.cmd +0 -30
- package/start.command +0 -20
- package/start.sh +0 -20
- package/tsconfig.base.json +0 -19
- /package/{server/bin → bin}/tmuxes.js +0 -0
package/client/src/api.ts
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
FileEntry,
|
|
3
|
-
FilePreview,
|
|
4
|
-
LaunchAgent,
|
|
5
|
-
SessionDirectory,
|
|
6
|
-
SessionInfo,
|
|
7
|
-
Target,
|
|
8
|
-
WindowInfo,
|
|
9
|
-
} from './types';
|
|
10
|
-
|
|
11
|
-
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
12
|
-
const res = await fetch(path, {
|
|
13
|
-
headers: { 'Content-Type': 'application/json' },
|
|
14
|
-
...init,
|
|
15
|
-
});
|
|
16
|
-
if (!res.ok) {
|
|
17
|
-
let message = `${res.status} ${res.statusText}`;
|
|
18
|
-
try {
|
|
19
|
-
const body = (await res.json()) as { error?: string };
|
|
20
|
-
if (body.error) message = body.error;
|
|
21
|
-
} catch {
|
|
22
|
-
/* non-JSON error body */
|
|
23
|
-
}
|
|
24
|
-
throw new ApiError(res.status, message);
|
|
25
|
-
}
|
|
26
|
-
if (res.status === 204) return undefined as T;
|
|
27
|
-
return (await res.json()) as T;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export class ApiError extends Error {
|
|
31
|
-
constructor(
|
|
32
|
-
public status: number,
|
|
33
|
-
message: string,
|
|
34
|
-
) {
|
|
35
|
-
super(message);
|
|
36
|
-
this.name = 'ApiError';
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export const api = {
|
|
41
|
-
getTargets(): Promise<{ targets: Target[] }> {
|
|
42
|
-
return request('/api/targets');
|
|
43
|
-
},
|
|
44
|
-
getSessions(targetId: string): Promise<{ sessions: SessionInfo[] }> {
|
|
45
|
-
return request(`/api/targets/${encodeURIComponent(targetId)}/sessions`);
|
|
46
|
-
},
|
|
47
|
-
createSession(
|
|
48
|
-
targetId: string,
|
|
49
|
-
body: { name?: string; command?: string; shell?: string },
|
|
50
|
-
): Promise<{ name: string }> {
|
|
51
|
-
return request(`/api/targets/${encodeURIComponent(targetId)}/sessions`, {
|
|
52
|
-
method: 'POST',
|
|
53
|
-
body: JSON.stringify(body),
|
|
54
|
-
});
|
|
55
|
-
},
|
|
56
|
-
renameSession(targetId: string, name: string, newName: string): Promise<{ name: string }> {
|
|
57
|
-
return request(
|
|
58
|
-
`/api/targets/${encodeURIComponent(targetId)}/sessions/${encodeURIComponent(name)}`,
|
|
59
|
-
{ method: 'PATCH', body: JSON.stringify({ newName }) },
|
|
60
|
-
);
|
|
61
|
-
},
|
|
62
|
-
killSession(targetId: string, name: string): Promise<void> {
|
|
63
|
-
return request(
|
|
64
|
-
`/api/targets/${encodeURIComponent(targetId)}/sessions/${encodeURIComponent(name)}`,
|
|
65
|
-
{ method: 'DELETE' },
|
|
66
|
-
);
|
|
67
|
-
},
|
|
68
|
-
launchAgent(targetId: string, name: string, agent: LaunchAgent): Promise<{ ok: true }> {
|
|
69
|
-
return request(
|
|
70
|
-
`/api/targets/${encodeURIComponent(targetId)}/sessions/${encodeURIComponent(name)}/agent`,
|
|
71
|
-
{ method: 'POST', body: JSON.stringify({ agent }) },
|
|
72
|
-
);
|
|
73
|
-
},
|
|
74
|
-
getWindows(targetId: string, name: string): Promise<{ windows: WindowInfo[] }> {
|
|
75
|
-
return request(
|
|
76
|
-
`/api/targets/${encodeURIComponent(targetId)}/sessions/${encodeURIComponent(name)}/windows`,
|
|
77
|
-
);
|
|
78
|
-
},
|
|
79
|
-
getCwd(targetId: string, name: string): Promise<{ cwd: string }> {
|
|
80
|
-
return request(
|
|
81
|
-
`/api/targets/${encodeURIComponent(targetId)}/sessions/${encodeURIComponent(name)}/cwd`,
|
|
82
|
-
);
|
|
83
|
-
},
|
|
84
|
-
listFiles(targetId: string, session: string, path: string): Promise<{ path: string; entries: FileEntry[] }> {
|
|
85
|
-
return request(
|
|
86
|
-
`/api/targets/${encodeURIComponent(targetId)}/files?session=${encodeURIComponent(session)}&path=${encodeURIComponent(path)}`,
|
|
87
|
-
);
|
|
88
|
-
},
|
|
89
|
-
getSessionFiles(targetId: string, session: string, path?: string): Promise<SessionDirectory> {
|
|
90
|
-
const qs = path === undefined ? '' : `?path=${encodeURIComponent(path)}`;
|
|
91
|
-
return request(
|
|
92
|
-
`/api/targets/${encodeURIComponent(targetId)}/sessions/${encodeURIComponent(session)}/files${qs}`,
|
|
93
|
-
);
|
|
94
|
-
},
|
|
95
|
-
getFile(targetId: string, session: string, path: string): Promise<FilePreview> {
|
|
96
|
-
return request(
|
|
97
|
-
`/api/targets/${encodeURIComponent(targetId)}/file?session=${encodeURIComponent(session)}&path=${encodeURIComponent(path)}`,
|
|
98
|
-
);
|
|
99
|
-
},
|
|
100
|
-
saveFile(targetId: string, session: string, path: string, content: string): Promise<{ ok: true }> {
|
|
101
|
-
return request(`/api/targets/${encodeURIComponent(targetId)}/file`, {
|
|
102
|
-
method: 'PUT',
|
|
103
|
-
body: JSON.stringify({ session, path, content }),
|
|
104
|
-
});
|
|
105
|
-
},
|
|
106
|
-
getFolders(targetId: string): Promise<{ folders: unknown[]; assign: Record<string, unknown> }> {
|
|
107
|
-
return request(`/api/targets/${encodeURIComponent(targetId)}/folders`);
|
|
108
|
-
},
|
|
109
|
-
saveFolders(
|
|
110
|
-
targetId: string,
|
|
111
|
-
payload: { folders: unknown[]; assign: Record<string, string> },
|
|
112
|
-
): Promise<{ ok: true }> {
|
|
113
|
-
return request(`/api/targets/${encodeURIComponent(targetId)}/folders`, {
|
|
114
|
-
method: 'PUT',
|
|
115
|
-
body: JSON.stringify(payload),
|
|
116
|
-
});
|
|
117
|
-
},
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
/** Build the WebSocket URL for an interactive attach (same-origin). */
|
|
121
|
-
export function terminalSocketUrl(targetId: string, session: string, cols: number, rows: number): string {
|
|
122
|
-
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
123
|
-
const params = new URLSearchParams({
|
|
124
|
-
target: targetId,
|
|
125
|
-
session,
|
|
126
|
-
cols: String(cols),
|
|
127
|
-
rows: String(rows),
|
|
128
|
-
});
|
|
129
|
-
return `${proto}://${location.host}/ws?${params.toString()}`;
|
|
130
|
-
}
|
package/client/src/attention.tsx
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createContext,
|
|
3
|
-
useCallback,
|
|
4
|
-
useContext,
|
|
5
|
-
useEffect,
|
|
6
|
-
useMemo,
|
|
7
|
-
useRef,
|
|
8
|
-
useState,
|
|
9
|
-
type ReactNode,
|
|
10
|
-
} from 'react';
|
|
11
|
-
import { useSettings } from './settings';
|
|
12
|
-
import type { AttentionReason } from './types';
|
|
13
|
-
|
|
14
|
-
/** Tracks sessions that need attention after an agent hook event and delivers
|
|
15
|
-
* the notification: a sidebar highlight, a short sound, and a flashing tab
|
|
16
|
-
* title while the tab is in the background. */
|
|
17
|
-
interface AttentionApi {
|
|
18
|
-
/** Current badge reason for a session, if any. */
|
|
19
|
-
reasonFor: (targetId: string, session: string) => AttentionReason | undefined;
|
|
20
|
-
/** Raise attention for a session. */
|
|
21
|
-
fire: (targetId: string, session: string, reason: AttentionReason) => void;
|
|
22
|
-
/** A session became active again — drop its badge. */
|
|
23
|
-
clearAlert: (targetId: string, session: string) => void;
|
|
24
|
-
/** The user is now looking at this session — acknowledge its alert. */
|
|
25
|
-
setActive: (targetId: string | null, session: string | null) => void;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const AttentionContext = createContext<AttentionApi | null>(null);
|
|
29
|
-
|
|
30
|
-
const keyOf = (targetId: string, session: string) => `${targetId}\u0000${session}`;
|
|
31
|
-
|
|
32
|
-
let audioCtx: AudioContext | null = null;
|
|
33
|
-
function playPing(): void {
|
|
34
|
-
try {
|
|
35
|
-
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
|
36
|
-
if (!Ctor) return;
|
|
37
|
-
audioCtx ??= new Ctor();
|
|
38
|
-
const ctx = audioCtx;
|
|
39
|
-
if (ctx.state === 'suspended') void ctx.resume();
|
|
40
|
-
const osc = ctx.createOscillator();
|
|
41
|
-
const gain = ctx.createGain();
|
|
42
|
-
osc.connect(gain);
|
|
43
|
-
gain.connect(ctx.destination);
|
|
44
|
-
osc.type = 'sine';
|
|
45
|
-
osc.frequency.value = 880;
|
|
46
|
-
const t = ctx.currentTime;
|
|
47
|
-
gain.gain.setValueAtTime(0.0001, t);
|
|
48
|
-
gain.gain.exponentialRampToValueAtTime(0.18, t + 0.01);
|
|
49
|
-
gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.28);
|
|
50
|
-
osc.start(t);
|
|
51
|
-
osc.stop(t + 0.3);
|
|
52
|
-
} catch {
|
|
53
|
-
/* audio not available / blocked before a gesture — ignore */
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function AttentionProvider({ children }: { children: ReactNode }) {
|
|
58
|
-
const { settings } = useSettings();
|
|
59
|
-
const [alerts, setAlerts] = useState<Record<string, AttentionReason>>({});
|
|
60
|
-
const activeKey = useRef<string | null>(null);
|
|
61
|
-
// Keep the latest sound preference readable inside async callbacks.
|
|
62
|
-
const soundOn = useRef(settings.notifySound);
|
|
63
|
-
soundOn.current = settings.notifySound;
|
|
64
|
-
|
|
65
|
-
const fire = useCallback<AttentionApi['fire']>((targetId, session, reason) => {
|
|
66
|
-
if (!settings.notifyAttention) return;
|
|
67
|
-
const key = keyOf(targetId, session);
|
|
68
|
-
// The user is already watching this session in the foreground — no need.
|
|
69
|
-
if (key === activeKey.current && !document.hidden) return;
|
|
70
|
-
|
|
71
|
-
setAlerts((prev) => ({ ...prev, [key]: reason }));
|
|
72
|
-
if (soundOn.current) playPing();
|
|
73
|
-
}, [settings.notifyAttention]);
|
|
74
|
-
|
|
75
|
-
const clearAlert = useCallback<AttentionApi['clearAlert']>((targetId, session) => {
|
|
76
|
-
const key = keyOf(targetId, session);
|
|
77
|
-
setAlerts((prev) => {
|
|
78
|
-
if (!(key in prev)) return prev;
|
|
79
|
-
const next = { ...prev };
|
|
80
|
-
delete next[key];
|
|
81
|
-
return next;
|
|
82
|
-
});
|
|
83
|
-
}, []);
|
|
84
|
-
|
|
85
|
-
const setActive = useCallback<AttentionApi['setActive']>((targetId, session) => {
|
|
86
|
-
const key = targetId && session ? keyOf(targetId, session) : null;
|
|
87
|
-
activeKey.current = key;
|
|
88
|
-
if (!key) return;
|
|
89
|
-
setAlerts((prev) => {
|
|
90
|
-
if (!(key in prev)) return prev;
|
|
91
|
-
const next = { ...prev };
|
|
92
|
-
delete next[key];
|
|
93
|
-
return next;
|
|
94
|
-
});
|
|
95
|
-
}, []);
|
|
96
|
-
|
|
97
|
-
const reasonFor = useCallback<AttentionApi['reasonFor']>(
|
|
98
|
-
(targetId, session) => alerts[keyOf(targetId, session)],
|
|
99
|
-
[alerts],
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
// Flash the tab title while there are pending alerts and the tab is hidden.
|
|
103
|
-
useEffect(() => {
|
|
104
|
-
const count = Object.keys(alerts).length;
|
|
105
|
-
const base = 'tmuxes';
|
|
106
|
-
if (count === 0) {
|
|
107
|
-
document.title = base;
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
let on = false;
|
|
111
|
-
const render = () => {
|
|
112
|
-
if (document.hidden) {
|
|
113
|
-
on = !on;
|
|
114
|
-
document.title = on ? `🔔 (${count}) ${base}` : base;
|
|
115
|
-
} else {
|
|
116
|
-
document.title = base;
|
|
117
|
-
}
|
|
118
|
-
};
|
|
119
|
-
render();
|
|
120
|
-
const id = window.setInterval(render, 1000);
|
|
121
|
-
const onVis = () => {
|
|
122
|
-
if (!document.hidden) {
|
|
123
|
-
// Foreground again: stop flashing; acknowledge whatever is being viewed.
|
|
124
|
-
document.title = base;
|
|
125
|
-
if (activeKey.current) {
|
|
126
|
-
const key = activeKey.current;
|
|
127
|
-
setAlerts((prev) => {
|
|
128
|
-
if (!(key in prev)) return prev;
|
|
129
|
-
const next = { ...prev };
|
|
130
|
-
delete next[key];
|
|
131
|
-
return next;
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
} else {
|
|
135
|
-
render();
|
|
136
|
-
}
|
|
137
|
-
};
|
|
138
|
-
document.addEventListener('visibilitychange', onVis);
|
|
139
|
-
return () => {
|
|
140
|
-
window.clearInterval(id);
|
|
141
|
-
document.removeEventListener('visibilitychange', onVis);
|
|
142
|
-
document.title = base;
|
|
143
|
-
};
|
|
144
|
-
}, [alerts]);
|
|
145
|
-
|
|
146
|
-
const value = useMemo<AttentionApi>(
|
|
147
|
-
() => ({ reasonFor, fire, clearAlert, setActive }),
|
|
148
|
-
[reasonFor, fire, clearAlert, setActive],
|
|
149
|
-
);
|
|
150
|
-
return <AttentionContext.Provider value={value}>{children}</AttentionContext.Provider>;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export function useAttention(): AttentionApi {
|
|
154
|
-
const ctx = useContext(AttentionContext);
|
|
155
|
-
if (!ctx) throw new Error('useAttention must be used within AttentionProvider');
|
|
156
|
-
return ctx;
|
|
157
|
-
}
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
-
import type { FileEntry, OpenFile, Selection } from '../types';
|
|
3
|
-
import { api, ApiError } from '../api';
|
|
4
|
-
import { basename, isTextFile, joinPath, parentPath } from '../util';
|
|
5
|
-
import { useI18n } from '../i18n';
|
|
6
|
-
|
|
7
|
-
interface Props {
|
|
8
|
-
selection: Selection | null;
|
|
9
|
-
openFile: OpenFile | null;
|
|
10
|
-
/** Native shell sessions have no tmux cwd — disables the file browser. */
|
|
11
|
-
enabled: boolean;
|
|
12
|
-
/** Pause automatic polling after SSH errors to avoid repeated failed attempts. */
|
|
13
|
-
pauseOnError: boolean;
|
|
14
|
-
onOpenFile: (file: OpenFile) => void;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const POLL_MS = 3000;
|
|
18
|
-
|
|
19
|
-
function samePath(a: string | null, b: string | null): boolean {
|
|
20
|
-
if (!a || !b) return false;
|
|
21
|
-
const clean = (p: string) => (p === '/' ? p : p.replace(/\/+$/, ''));
|
|
22
|
-
return clean(a) === clean(b);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Bottom of the sidebar: the files/folders of the selected session's current
|
|
26
|
-
* working directory. Follows the session's cwd until you navigate away. */
|
|
27
|
-
export function FileExplorer({ selection, openFile, enabled, pauseOnError, onOpenFile }: Props) {
|
|
28
|
-
const { t } = useI18n();
|
|
29
|
-
const [cwd, setCwd] = useState<string | null>(null);
|
|
30
|
-
const [manualPath, setManualPath] = useState<string | null>(null);
|
|
31
|
-
const [entries, setEntries] = useState<FileEntry[]>([]);
|
|
32
|
-
const [error, setError] = useState<string | null>(null);
|
|
33
|
-
const pollingPaused = useRef(false);
|
|
34
|
-
|
|
35
|
-
const path = manualPath ?? cwd;
|
|
36
|
-
const following = manualPath === null;
|
|
37
|
-
const targetId = selection?.targetId;
|
|
38
|
-
const session = selection?.session;
|
|
39
|
-
|
|
40
|
-
// New selection → re-follow that session's cwd.
|
|
41
|
-
useEffect(() => {
|
|
42
|
-
setManualPath(null);
|
|
43
|
-
setCwd(null);
|
|
44
|
-
setEntries([]);
|
|
45
|
-
setError(null);
|
|
46
|
-
pollingPaused.current = false;
|
|
47
|
-
}, [targetId, session]);
|
|
48
|
-
|
|
49
|
-
const tick = useCallback(async () => {
|
|
50
|
-
if (pollingPaused.current) return;
|
|
51
|
-
if (!targetId || !session) return;
|
|
52
|
-
try {
|
|
53
|
-
const { cwd: latest, entries } = await api.getSessionFiles(
|
|
54
|
-
targetId,
|
|
55
|
-
session,
|
|
56
|
-
manualPath ?? undefined,
|
|
57
|
-
);
|
|
58
|
-
setCwd(latest);
|
|
59
|
-
setEntries(entries);
|
|
60
|
-
setError(null);
|
|
61
|
-
} catch (e) {
|
|
62
|
-
setError(e instanceof ApiError ? e.message : t.cannotListDirectory);
|
|
63
|
-
if (pauseOnError) pollingPaused.current = true;
|
|
64
|
-
}
|
|
65
|
-
}, [targetId, session, manualPath, pauseOnError, t.cannotListDirectory]);
|
|
66
|
-
|
|
67
|
-
const manualReconnect = useCallback(() => {
|
|
68
|
-
pollingPaused.current = false;
|
|
69
|
-
setError(null);
|
|
70
|
-
void tick();
|
|
71
|
-
}, [tick]);
|
|
72
|
-
|
|
73
|
-
useEffect(() => {
|
|
74
|
-
if (!targetId || !session || !enabled) return;
|
|
75
|
-
void tick();
|
|
76
|
-
const id = window.setInterval(() => void tick(), POLL_MS);
|
|
77
|
-
return () => window.clearInterval(id);
|
|
78
|
-
}, [targetId, session, enabled, tick]);
|
|
79
|
-
|
|
80
|
-
if (!selection) {
|
|
81
|
-
return (
|
|
82
|
-
<div className="explorer">
|
|
83
|
-
<div className="explorer-empty">{t.selectSessionForFiles}</div>
|
|
84
|
-
</div>
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (!enabled) {
|
|
89
|
-
return (
|
|
90
|
-
<div className="explorer">
|
|
91
|
-
<div className="explorer-empty">{t.fileBrowserUnavailable}</div>
|
|
92
|
-
</div>
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const navigate = (entry: FileEntry) => {
|
|
97
|
-
if (!path) return;
|
|
98
|
-
const full = joinPath(path, entry.name);
|
|
99
|
-
if (entry.type === 'dir') {
|
|
100
|
-
setManualPath(full);
|
|
101
|
-
} else if (isTextFile(entry.name)) {
|
|
102
|
-
onOpenFile({ targetId: selection.targetId, session: selection.session, path: full, name: entry.name });
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
const atRoot = samePath(path, cwd);
|
|
107
|
-
|
|
108
|
-
return (
|
|
109
|
-
<div className="explorer">
|
|
110
|
-
<div className="explorer-head">
|
|
111
|
-
<span className="explorer-path" title={path ?? ''}>
|
|
112
|
-
{path ? basename(path) : '…'}
|
|
113
|
-
</span>
|
|
114
|
-
<span className="explorer-actions">
|
|
115
|
-
{!following && (
|
|
116
|
-
<button title={t.backToSessionDirectory} onClick={() => setManualPath(null)}>
|
|
117
|
-
⌂
|
|
118
|
-
</button>
|
|
119
|
-
)}
|
|
120
|
-
<button title={t.upOneLevel} disabled={!path || path === '/' || atRoot} onClick={() => path && setManualPath(parentPath(path))}>
|
|
121
|
-
↑
|
|
122
|
-
</button>
|
|
123
|
-
</span>
|
|
124
|
-
</div>
|
|
125
|
-
{error && (
|
|
126
|
-
<div className="error-line">
|
|
127
|
-
<span>{error}</span>
|
|
128
|
-
{pauseOnError && (
|
|
129
|
-
<button onClick={manualReconnect}>
|
|
130
|
-
{t.reconnect}
|
|
131
|
-
</button>
|
|
132
|
-
)}
|
|
133
|
-
</div>
|
|
134
|
-
)}
|
|
135
|
-
<div className="explorer-list">
|
|
136
|
-
{entries.map((e) => {
|
|
137
|
-
const openable = e.type === 'dir' || isTextFile(e.name);
|
|
138
|
-
const isOpen =
|
|
139
|
-
openFile?.targetId === selection.targetId && path && openFile.path === joinPath(path, e.name);
|
|
140
|
-
return (
|
|
141
|
-
<div
|
|
142
|
-
key={e.name}
|
|
143
|
-
className={`file-row ${e.type} ${openable ? '' : 'disabled'} ${isOpen ? 'open' : ''}`}
|
|
144
|
-
onClick={() => openable && navigate(e)}
|
|
145
|
-
title={e.name}
|
|
146
|
-
>
|
|
147
|
-
<span className="file-icon">{e.type === 'dir' ? '📁' : isTextFile(e.name) ? '📄' : '▫'}</span>
|
|
148
|
-
<span className="file-name">{e.name}</span>
|
|
149
|
-
</div>
|
|
150
|
-
);
|
|
151
|
-
})}
|
|
152
|
-
{entries.length === 0 && !error && <div className="explorer-empty">{t.emptyDirectory}</div>}
|
|
153
|
-
</div>
|
|
154
|
-
</div>
|
|
155
|
-
);
|
|
156
|
-
}
|
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react';
|
|
2
|
-
import type { FilePreview, OpenFile } from '../types';
|
|
3
|
-
import { api, ApiError } from '../api';
|
|
4
|
-
import { useSettings } from '../settings';
|
|
5
|
-
import { useI18n } from '../i18n';
|
|
6
|
-
|
|
7
|
-
interface Props {
|
|
8
|
-
file: OpenFile;
|
|
9
|
-
onClose: () => void;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/** New undo checkpoint after this idle gap, so a burst of typing collapses
|
|
13
|
-
* into a single undo step. */
|
|
14
|
-
const COALESCE_MS = 500;
|
|
15
|
-
|
|
16
|
-
/** Read-only preview + editor for a text file. Editable only when the file is
|
|
17
|
-
* text and was not truncated (saving a truncated preview would lose data). */
|
|
18
|
-
export function FileViewer({ file, onClose }: Props) {
|
|
19
|
-
const { settings } = useSettings();
|
|
20
|
-
const { t } = useI18n();
|
|
21
|
-
const taRef = useRef<HTMLTextAreaElement>(null);
|
|
22
|
-
const lastEditRef = useRef(0);
|
|
23
|
-
|
|
24
|
-
const [preview, setPreview] = useState<FilePreview | null>(null);
|
|
25
|
-
const [loadError, setLoadError] = useState<string | null>(null);
|
|
26
|
-
const [loading, setLoading] = useState(true);
|
|
27
|
-
|
|
28
|
-
const [text, setText] = useState('');
|
|
29
|
-
const [baseline, setBaseline] = useState('');
|
|
30
|
-
const [past, setPast] = useState<string[]>([]);
|
|
31
|
-
const [future, setFuture] = useState<string[]>([]);
|
|
32
|
-
const [saving, setSaving] = useState(false);
|
|
33
|
-
const [saveError, setSaveError] = useState<string | null>(null);
|
|
34
|
-
|
|
35
|
-
const editable = !!preview && !preview.binary && !preview.truncated;
|
|
36
|
-
const dirty = editable && text !== baseline;
|
|
37
|
-
|
|
38
|
-
// Load (or reload) the file.
|
|
39
|
-
useEffect(() => {
|
|
40
|
-
let cancelled = false;
|
|
41
|
-
setLoading(true);
|
|
42
|
-
setLoadError(null);
|
|
43
|
-
setSaveError(null);
|
|
44
|
-
setPreview(null);
|
|
45
|
-
setPast([]);
|
|
46
|
-
setFuture([]);
|
|
47
|
-
lastEditRef.current = 0;
|
|
48
|
-
api
|
|
49
|
-
.getFile(file.targetId, file.session, file.path)
|
|
50
|
-
.then((p) => {
|
|
51
|
-
if (cancelled) return;
|
|
52
|
-
setPreview(p);
|
|
53
|
-
setText(p.content);
|
|
54
|
-
setBaseline(p.content);
|
|
55
|
-
})
|
|
56
|
-
.catch((e) => {
|
|
57
|
-
if (!cancelled) setLoadError(e instanceof ApiError ? e.message : t.failedReadFile);
|
|
58
|
-
})
|
|
59
|
-
.finally(() => {
|
|
60
|
-
if (!cancelled) setLoading(false);
|
|
61
|
-
});
|
|
62
|
-
return () => {
|
|
63
|
-
cancelled = true;
|
|
64
|
-
};
|
|
65
|
-
}, [file.targetId, file.session, file.path, t.failedReadFile]);
|
|
66
|
-
|
|
67
|
-
const onType = (next: string) => {
|
|
68
|
-
const now = Date.now();
|
|
69
|
-
if (now - lastEditRef.current > COALESCE_MS) {
|
|
70
|
-
setPast((p) => [...p, text]); // checkpoint = value at start of this burst
|
|
71
|
-
setFuture([]);
|
|
72
|
-
}
|
|
73
|
-
lastEditRef.current = now;
|
|
74
|
-
setText(next);
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
// Flat functional updates (no nested setState) so the handlers are
|
|
78
|
-
// StrictMode-safe; past/future/text come from the current render.
|
|
79
|
-
const undo = () => {
|
|
80
|
-
if (past.length === 0) return;
|
|
81
|
-
const prev = past[past.length - 1];
|
|
82
|
-
setFuture((f) => [...f, text]);
|
|
83
|
-
setPast((p) => p.slice(0, -1));
|
|
84
|
-
setText(prev);
|
|
85
|
-
lastEditRef.current = 0;
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
const redo = () => {
|
|
89
|
-
if (future.length === 0) return;
|
|
90
|
-
const next = future[future.length - 1];
|
|
91
|
-
setPast((p) => [...p, text]);
|
|
92
|
-
setFuture((f) => f.slice(0, -1));
|
|
93
|
-
setText(next);
|
|
94
|
-
lastEditRef.current = 0;
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
const save = async () => {
|
|
98
|
-
if (!editable || !dirty || saving) return;
|
|
99
|
-
setSaving(true);
|
|
100
|
-
setSaveError(null);
|
|
101
|
-
try {
|
|
102
|
-
await api.saveFile(file.targetId, file.session, file.path, text);
|
|
103
|
-
setBaseline(text);
|
|
104
|
-
} catch (e) {
|
|
105
|
-
setSaveError(e instanceof ApiError ? e.message : t.saveFailed);
|
|
106
|
-
} finally {
|
|
107
|
-
setSaving(false);
|
|
108
|
-
}
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
112
|
-
const mod = e.metaKey || e.ctrlKey;
|
|
113
|
-
const k = e.key.toLowerCase();
|
|
114
|
-
if (mod && k === 's') {
|
|
115
|
-
e.preventDefault();
|
|
116
|
-
void save();
|
|
117
|
-
} else if (mod && k === 'z' && !e.shiftKey) {
|
|
118
|
-
e.preventDefault();
|
|
119
|
-
undo();
|
|
120
|
-
} else if (mod && (k === 'y' || (k === 'z' && e.shiftKey))) {
|
|
121
|
-
e.preventDefault();
|
|
122
|
-
redo();
|
|
123
|
-
} else if (e.key === 'Tab') {
|
|
124
|
-
e.preventDefault();
|
|
125
|
-
const ta = taRef.current;
|
|
126
|
-
if (!ta) return;
|
|
127
|
-
const start = ta.selectionStart;
|
|
128
|
-
const end = ta.selectionEnd;
|
|
129
|
-
onType(text.slice(0, start) + ' ' + text.slice(end));
|
|
130
|
-
requestAnimationFrame(() => {
|
|
131
|
-
ta.selectionStart = ta.selectionEnd = start + 2;
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
const requestClose = () => {
|
|
137
|
-
if (dirty && !confirm(t.discardUnsaved)) return;
|
|
138
|
-
onClose();
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
return (
|
|
142
|
-
<div className="viewer">
|
|
143
|
-
<div className="viewer-head">
|
|
144
|
-
<span className="viewer-name" title={file.path}>
|
|
145
|
-
{dirty && <span className="dirty-dot" title={t.unsavedChanges}>●</span>}
|
|
146
|
-
{file.name}
|
|
147
|
-
</span>
|
|
148
|
-
<span className="viewer-path">{file.path}</span>
|
|
149
|
-
<div className="viewer-head-spacer" />
|
|
150
|
-
{editable && (
|
|
151
|
-
<>
|
|
152
|
-
<button onClick={undo} disabled={past.length === 0} title={t.undo}>
|
|
153
|
-
↶
|
|
154
|
-
</button>
|
|
155
|
-
<button onClick={redo} disabled={future.length === 0} title={t.redo}>
|
|
156
|
-
↷
|
|
157
|
-
</button>
|
|
158
|
-
<button className="primary" onClick={() => void save()} disabled={!dirty || saving} title={t.saveShortcut}>
|
|
159
|
-
{saving ? t.saving : t.save}
|
|
160
|
-
</button>
|
|
161
|
-
</>
|
|
162
|
-
)}
|
|
163
|
-
{preview?.truncated && <span className="viewer-note">{t.truncatedReadOnly}</span>}
|
|
164
|
-
<button onClick={requestClose} title={t.closeFile}>
|
|
165
|
-
✕
|
|
166
|
-
</button>
|
|
167
|
-
</div>
|
|
168
|
-
|
|
169
|
-
{saveError && <div className="viewer-msg error">{saveError}</div>}
|
|
170
|
-
|
|
171
|
-
<div className="viewer-body">
|
|
172
|
-
{loading && <div className="viewer-msg">{t.loading}</div>}
|
|
173
|
-
{loadError && <div className="viewer-msg error">{loadError}</div>}
|
|
174
|
-
{preview && preview.binary && <div className="viewer-msg">{t.binaryNotShown}</div>}
|
|
175
|
-
{preview && !preview.binary && editable && (
|
|
176
|
-
<textarea
|
|
177
|
-
ref={taRef}
|
|
178
|
-
className="viewer-editor"
|
|
179
|
-
style={{ fontSize: settings.viewerFontSize }}
|
|
180
|
-
value={text}
|
|
181
|
-
spellCheck={false}
|
|
182
|
-
onChange={(e) => onType(e.target.value)}
|
|
183
|
-
onKeyDown={onKeyDown}
|
|
184
|
-
/>
|
|
185
|
-
)}
|
|
186
|
-
{preview && !preview.binary && !editable && (
|
|
187
|
-
<pre className="viewer-pre" style={{ fontSize: settings.viewerFontSize }}>
|
|
188
|
-
{preview.content}
|
|
189
|
-
</pre>
|
|
190
|
-
)}
|
|
191
|
-
</div>
|
|
192
|
-
</div>
|
|
193
|
-
);
|
|
194
|
-
}
|