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.
Files changed (140) hide show
  1. package/dist/agentHooks.js +91 -0
  2. package/dist/agentHooks.js.map +1 -0
  3. package/dist/agentOutput.js +30 -0
  4. package/dist/agentOutput.js.map +1 -0
  5. package/dist/agentState.js +45 -0
  6. package/dist/agentState.js.map +1 -0
  7. package/dist/config.js +32 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/exe.js +37 -0
  10. package/dist/exe.js.map +1 -0
  11. package/dist/exec.js +43 -0
  12. package/dist/exec.js.map +1 -0
  13. package/dist/files.js +308 -0
  14. package/dist/files.js.map +1 -0
  15. package/dist/foldersStore.js +103 -0
  16. package/dist/foldersStore.js.map +1 -0
  17. package/dist/index.js +117 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/logger.js +16 -0
  20. package/dist/logger.js.map +1 -0
  21. package/{server/src/monitor.ts → dist/monitor.js} +9 -10
  22. package/dist/monitor.js.map +1 -0
  23. package/dist/openBrowser.js +31 -0
  24. package/dist/openBrowser.js.map +1 -0
  25. package/{server/src/platform.ts → dist/platform.js} +5 -4
  26. package/dist/platform.js.map +1 -0
  27. package/dist/rest/router.js +198 -0
  28. package/dist/rest/router.js.map +1 -0
  29. package/dist/targetCommand.js +60 -0
  30. package/dist/targetCommand.js.map +1 -0
  31. package/dist/targets.js +131 -0
  32. package/dist/targets.js.map +1 -0
  33. package/dist/tmux/builder.js +174 -0
  34. package/dist/tmux/builder.js.map +1 -0
  35. package/dist/tmux/formats.js +61 -0
  36. package/dist/tmux/formats.js.map +1 -0
  37. package/dist/tmux/sessions.js +157 -0
  38. package/dist/tmux/sessions.js.map +1 -0
  39. package/dist/validate.js +65 -0
  40. package/dist/validate.js.map +1 -0
  41. package/dist/windowsSsh.js +209 -0
  42. package/dist/windowsSsh.js.map +1 -0
  43. package/dist/winshell/manager.js +267 -0
  44. package/dist/winshell/manager.js.map +1 -0
  45. package/dist/ws/protocol.js +4 -0
  46. package/dist/ws/protocol.js.map +1 -0
  47. package/dist/ws/sshState.js +35 -0
  48. package/dist/ws/sshState.js.map +1 -0
  49. package/dist/ws/terminalSession.js +204 -0
  50. package/dist/ws/terminalSession.js.map +1 -0
  51. package/dist/ws/wsServer.js +151 -0
  52. package/dist/ws/wsServer.js.map +1 -0
  53. package/dist/wsl.js +35 -0
  54. package/dist/wsl.js.map +1 -0
  55. package/package.json +49 -16
  56. package/public/assets/index-D_X5SnGx.css +1 -0
  57. package/public/assets/index-Dl69CPyt.js +44 -0
  58. package/{client → public}/index.html +3 -2
  59. package/.node-version +0 -1
  60. package/.nvmrc +0 -1
  61. package/.tmp-npm-cache/_cacache/content-v2/sha512/43/27/5e000b8b9c56a6ccc66f709485499f4304e2cb1982582ba571321c07b3ef56fcabd2c671898cc8003365a0485b6fd8e73e7b17b073cec0f7d1628c1a99df +0 -0
  62. package/.tmp-npm-cache/_cacache/content-v2/sha512/51/cf/4301295d74559ed494bae160d54d8741077f89faebb311882ac065019246951e7b53f3dcb913793c42b331e14c7070c4810c3cdc27a427d103a7db4614e0 +0 -0
  63. package/.tmp-npm-cache/_cacache/content-v2/sha512/c3/4d/d68a454a916e74c2617f586fbf770981b33811d667c2547eb0e9fc21938f4ee7e98f1ceee4bde8ad8815b5f6efe21b60eee798837d68f51a3340d7e5bb7a +0 -0
  64. package/.tmp-npm-cache/_cacache/content-v2/sha512/fe/40/2abfbefc96299e8bf714aa91d62607190ae299e102cf5933db2e2904640d65d25d67dbbb6fa2ddc92a17f00b9dbfdf2e37487f67d96ec36c64a285b59a7d +0 -0
  65. package/.tmp-npm-cache/_cacache/index-v5/27/fe/81a3de6ce7ae3d1e41a3421de20c5629998c4ee5d0ffe2037630f03b03b2 +0 -4
  66. package/.tmp-npm-cache/_cacache/index-v5/65/22/dd66711f62681fce09aabb2357a2907b4a0c778ac5227c4baf9603fd86e8 +0 -4
  67. package/.tmp-npm-cache/_update-notifier-last-checked +0 -0
  68. package/AGENTS.md +0 -15
  69. package/CLAUDE.md +0 -3
  70. package/README.en.md +0 -304
  71. package/SECURITY.md +0 -31
  72. package/client/package.json +0 -29
  73. package/client/src/App.tsx +0 -123
  74. package/client/src/activity.ts +0 -5
  75. package/client/src/api.ts +0 -130
  76. package/client/src/attention.tsx +0 -157
  77. package/client/src/components/FileExplorer.tsx +0 -156
  78. package/client/src/components/FileViewer.tsx +0 -194
  79. package/client/src/components/SessionRow.tsx +0 -108
  80. package/client/src/components/SessionTree.tsx +0 -197
  81. package/client/src/components/SettingsButton.tsx +0 -122
  82. package/client/src/components/Sidebar.tsx +0 -96
  83. package/client/src/components/StatusBanner.tsx +0 -31
  84. package/client/src/components/TargetGroup.tsx +0 -275
  85. package/client/src/components/TerminalPanel.tsx +0 -192
  86. package/client/src/folders.ts +0 -245
  87. package/client/src/hooks/useTerminal.ts +0 -67
  88. package/client/src/hooks/useTmuxSocket.ts +0 -65
  89. package/client/src/i18n.ts +0 -213
  90. package/client/src/main.tsx +0 -17
  91. package/client/src/settings.tsx +0 -87
  92. package/client/src/styles.css +0 -723
  93. package/client/src/types.ts +0 -93
  94. package/client/src/util.ts +0 -65
  95. package/client/tsconfig.json +0 -13
  96. package/client/vite.config.ts +0 -15
  97. package/fig/fig1.png +0 -0
  98. package/scripts/prepack.mjs +0 -35
  99. package/server/package.json +0 -61
  100. package/server/src/agentHooks.ts +0 -120
  101. package/server/src/agentOutput.ts +0 -36
  102. package/server/src/agentState.ts +0 -70
  103. package/server/src/config.ts +0 -31
  104. package/server/src/exe.ts +0 -34
  105. package/server/src/exec.ts +0 -61
  106. package/server/src/files.ts +0 -330
  107. package/server/src/foldersStore.ts +0 -114
  108. package/server/src/index.ts +0 -114
  109. package/server/src/logger.ts +0 -16
  110. package/server/src/openBrowser.ts +0 -28
  111. package/server/src/rest/router.ts +0 -290
  112. package/server/src/targetCommand.ts +0 -79
  113. package/server/src/targets.ts +0 -152
  114. package/server/src/tmux/builder.ts +0 -198
  115. package/server/src/tmux/formats.ts +0 -95
  116. package/server/src/tmux/sessions.ts +0 -204
  117. package/server/src/validate.ts +0 -79
  118. package/server/src/windowsSsh.ts +0 -239
  119. package/server/src/winshell/manager.ts +0 -296
  120. package/server/src/ws/protocol.ts +0 -15
  121. package/server/src/ws/sshState.ts +0 -36
  122. package/server/src/ws/terminalSession.ts +0 -207
  123. package/server/src/ws/wsServer.ts +0 -153
  124. package/server/src/wsl.ts +0 -38
  125. package/server/test/agentHooks.test.ts +0 -66
  126. package/server/test/agentOutput.test.ts +0 -26
  127. package/server/test/agentState.test.ts +0 -24
  128. package/server/test/builder.test.ts +0 -162
  129. package/server/test/files.test.ts +0 -81
  130. package/server/test/formats.test.ts +0 -123
  131. package/server/test/monitor.test.ts +0 -25
  132. package/server/test/validate.test.ts +0 -71
  133. package/server/test/wsl.test.ts +0 -18
  134. package/server/tsconfig.json +0 -9
  135. package/server/vitest.config.ts +0 -12
  136. package/start.cmd +0 -30
  137. package/start.command +0 -20
  138. package/start.sh +0 -20
  139. package/tsconfig.base.json +0 -19
  140. /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
- }
@@ -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
- }