tmuxes 0.1.7 → 0.1.9

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/.node-version +1 -0
  2. package/.nvmrc +1 -0
  3. package/.tmp-npm-cache/_cacache/content-v2/sha512/43/27/5e000b8b9c56a6ccc66f709485499f4304e2cb1982582ba571321c07b3ef56fcabd2c671898cc8003365a0485b6fd8e73e7b17b073cec0f7d1628c1a99df +0 -0
  4. package/.tmp-npm-cache/_cacache/content-v2/sha512/51/cf/4301295d74559ed494bae160d54d8741077f89faebb311882ac065019246951e7b53f3dcb913793c42b331e14c7070c4810c3cdc27a427d103a7db4614e0 +0 -0
  5. package/.tmp-npm-cache/_cacache/content-v2/sha512/c3/4d/d68a454a916e74c2617f586fbf770981b33811d667c2547eb0e9fc21938f4ee7e98f1ceee4bde8ad8815b5f6efe21b60eee798837d68f51a3340d7e5bb7a +0 -0
  6. package/.tmp-npm-cache/_cacache/content-v2/sha512/fe/40/2abfbefc96299e8bf714aa91d62607190ae299e102cf5933db2e2904640d65d25d67dbbb6fa2ddc92a17f00b9dbfdf2e37487f67d96ec36c64a285b59a7d +0 -0
  7. package/.tmp-npm-cache/_cacache/index-v5/27/fe/81a3de6ce7ae3d1e41a3421de20c5629998c4ee5d0ffe2037630f03b03b2 +4 -0
  8. package/.tmp-npm-cache/_cacache/index-v5/65/22/dd66711f62681fce09aabb2357a2907b4a0c778ac5227c4baf9603fd86e8 +4 -0
  9. package/.tmp-npm-cache/_update-notifier-last-checked +0 -0
  10. package/AGENTS.md +15 -0
  11. package/CLAUDE.md +3 -0
  12. package/LICENSE +21 -21
  13. package/README.en.md +304 -0
  14. package/README.md +301 -289
  15. package/SECURITY.md +31 -0
  16. package/{public → client}/index.html +12 -13
  17. package/client/package.json +29 -0
  18. package/client/src/App.tsx +123 -0
  19. package/client/src/activity.ts +5 -0
  20. package/client/src/api.ts +130 -0
  21. package/client/src/attention.tsx +157 -0
  22. package/client/src/components/FileExplorer.tsx +156 -0
  23. package/client/src/components/FileViewer.tsx +194 -0
  24. package/client/src/components/SessionRow.tsx +108 -0
  25. package/client/src/components/SessionTree.tsx +197 -0
  26. package/client/src/components/SettingsButton.tsx +122 -0
  27. package/client/src/components/Sidebar.tsx +96 -0
  28. package/client/src/components/StatusBanner.tsx +31 -0
  29. package/client/src/components/TargetGroup.tsx +275 -0
  30. package/client/src/components/TerminalPanel.tsx +192 -0
  31. package/client/src/folders.ts +245 -0
  32. package/client/src/hooks/useTerminal.ts +67 -0
  33. package/client/src/hooks/useTmuxSocket.ts +65 -0
  34. package/client/src/i18n.ts +213 -0
  35. package/client/src/main.tsx +17 -0
  36. package/client/src/settings.tsx +87 -0
  37. package/client/src/styles.css +723 -0
  38. package/client/src/types.ts +93 -0
  39. package/client/src/util.ts +65 -0
  40. package/client/tsconfig.json +13 -0
  41. package/client/vite.config.ts +15 -0
  42. package/fig/fig1.png +0 -0
  43. package/package.json +28 -61
  44. package/scripts/prepack.mjs +35 -0
  45. package/{bin → server/bin}/tmuxes.js +36 -36
  46. package/server/package.json +61 -0
  47. package/server/src/agentHooks.ts +120 -0
  48. package/server/src/agentOutput.ts +36 -0
  49. package/server/src/agentState.ts +70 -0
  50. package/server/src/config.ts +31 -0
  51. package/server/src/exe.ts +34 -0
  52. package/server/src/exec.ts +61 -0
  53. package/server/src/files.ts +330 -0
  54. package/server/src/foldersStore.ts +114 -0
  55. package/server/src/index.ts +114 -0
  56. package/server/src/logger.ts +16 -0
  57. package/{dist/monitor.js → server/src/monitor.ts} +10 -9
  58. package/server/src/openBrowser.ts +28 -0
  59. package/{dist/platform.js → server/src/platform.ts} +4 -5
  60. package/server/src/rest/router.ts +290 -0
  61. package/server/src/targetCommand.ts +79 -0
  62. package/server/src/targets.ts +152 -0
  63. package/server/src/tmux/builder.ts +198 -0
  64. package/server/src/tmux/formats.ts +95 -0
  65. package/server/src/tmux/sessions.ts +204 -0
  66. package/server/src/validate.ts +79 -0
  67. package/server/src/windowsSsh.ts +239 -0
  68. package/server/src/winshell/manager.ts +296 -0
  69. package/server/src/ws/protocol.ts +15 -0
  70. package/server/src/ws/sshState.ts +36 -0
  71. package/server/src/ws/terminalSession.ts +207 -0
  72. package/server/src/ws/wsServer.ts +153 -0
  73. package/server/src/wsl.ts +38 -0
  74. package/server/test/agentHooks.test.ts +66 -0
  75. package/server/test/agentOutput.test.ts +26 -0
  76. package/server/test/agentState.test.ts +24 -0
  77. package/server/test/builder.test.ts +162 -0
  78. package/server/test/files.test.ts +81 -0
  79. package/server/test/formats.test.ts +123 -0
  80. package/server/test/monitor.test.ts +25 -0
  81. package/server/test/validate.test.ts +71 -0
  82. package/server/test/wsl.test.ts +18 -0
  83. package/server/tsconfig.json +9 -0
  84. package/server/vitest.config.ts +12 -0
  85. package/start.cmd +30 -0
  86. package/start.command +20 -0
  87. package/start.sh +20 -0
  88. package/tsconfig.base.json +19 -0
  89. package/dist/agentHooks.js +0 -91
  90. package/dist/agentHooks.js.map +0 -1
  91. package/dist/agentOutput.js +0 -30
  92. package/dist/agentOutput.js.map +0 -1
  93. package/dist/agentState.js +0 -45
  94. package/dist/agentState.js.map +0 -1
  95. package/dist/config.js +0 -32
  96. package/dist/config.js.map +0 -1
  97. package/dist/exe.js +0 -37
  98. package/dist/exe.js.map +0 -1
  99. package/dist/exec.js +0 -43
  100. package/dist/exec.js.map +0 -1
  101. package/dist/files.js +0 -243
  102. package/dist/files.js.map +0 -1
  103. package/dist/foldersStore.js +0 -103
  104. package/dist/foldersStore.js.map +0 -1
  105. package/dist/index.js +0 -117
  106. package/dist/index.js.map +0 -1
  107. package/dist/logger.js +0 -16
  108. package/dist/logger.js.map +0 -1
  109. package/dist/monitor.js.map +0 -1
  110. package/dist/openBrowser.js +0 -31
  111. package/dist/openBrowser.js.map +0 -1
  112. package/dist/platform.js.map +0 -1
  113. package/dist/rest/router.js +0 -190
  114. package/dist/rest/router.js.map +0 -1
  115. package/dist/targetCommand.js +0 -41
  116. package/dist/targetCommand.js.map +0 -1
  117. package/dist/targets.js +0 -131
  118. package/dist/targets.js.map +0 -1
  119. package/dist/tmux/builder.js +0 -173
  120. package/dist/tmux/builder.js.map +0 -1
  121. package/dist/tmux/formats.js +0 -61
  122. package/dist/tmux/formats.js.map +0 -1
  123. package/dist/tmux/sessions.js +0 -157
  124. package/dist/tmux/sessions.js.map +0 -1
  125. package/dist/validate.js +0 -65
  126. package/dist/validate.js.map +0 -1
  127. package/dist/winshell/manager.js +0 -267
  128. package/dist/winshell/manager.js.map +0 -1
  129. package/dist/ws/protocol.js +0 -4
  130. package/dist/ws/protocol.js.map +0 -1
  131. package/dist/ws/sshState.js +0 -35
  132. package/dist/ws/sshState.js.map +0 -1
  133. package/dist/ws/terminalSession.js +0 -204
  134. package/dist/ws/terminalSession.js.map +0 -1
  135. package/dist/ws/wsServer.js +0 -151
  136. package/dist/ws/wsServer.js.map +0 -1
  137. package/dist/wsl.js +0 -35
  138. package/dist/wsl.js.map +0 -1
  139. package/public/assets/index-BpVrfoZw.js +0 -44
  140. package/public/assets/index-D_X5SnGx.css +0 -1
@@ -0,0 +1,194 @@
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
+ }
@@ -0,0 +1,108 @@
1
+ import { useState } from 'react';
2
+ import type { SessionInfo } from '../types';
3
+ import { ago, isValidSessionName } from '../util';
4
+ import { useAttention } from '../attention';
5
+ import { isSessionActive } from '../activity';
6
+ import { agentStatusLabel, attentionText, attentionTitle, useI18n } from '../i18n';
7
+
8
+ interface Props {
9
+ targetId: string;
10
+ session: SessionInfo;
11
+ selected: boolean;
12
+ nowMs: number;
13
+ depth?: number;
14
+ onSelect: () => void;
15
+ onRename: (newName: string) => void;
16
+ onKill: () => void;
17
+ onDragStart?: (e: React.DragEvent) => void;
18
+ }
19
+
20
+ export function SessionRow({
21
+ targetId,
22
+ session,
23
+ selected,
24
+ nowMs,
25
+ depth = 0,
26
+ onSelect,
27
+ onRename,
28
+ onKill,
29
+ onDragStart,
30
+ }: Props) {
31
+ const attention = useAttention();
32
+ const { language, t } = useI18n();
33
+ const reason = attention.reasonFor(targetId, session.name);
34
+ const active = isSessionActive(session);
35
+ const [renaming, setRenaming] = useState(false);
36
+ const [draft, setDraft] = useState(session.name);
37
+
38
+ const commitRename = () => {
39
+ const next = draft.trim();
40
+ if (next && next !== session.name && isValidSessionName(next)) {
41
+ onRename(next);
42
+ }
43
+ setRenaming(false);
44
+ };
45
+
46
+ const status = agentStatusLabel(session, t);
47
+
48
+ if (renaming) {
49
+ return (
50
+ <div className="session-row" style={{ paddingLeft: 8 + depth * 14 }}>
51
+ <input
52
+ autoFocus
53
+ value={draft}
54
+ onChange={(e) => setDraft(e.target.value)}
55
+ onKeyDown={(e) => {
56
+ if (e.key === 'Enter') commitRename();
57
+ else if (e.key === 'Escape') setRenaming(false);
58
+ }}
59
+ onBlur={() => setRenaming(false)}
60
+ />
61
+ </div>
62
+ );
63
+ }
64
+
65
+ return (
66
+ <div
67
+ className={`session-row ${selected ? 'selected' : ''} ${reason ? 'attention' : ''}`}
68
+ style={{ paddingLeft: 8 + depth * 14 }}
69
+ draggable={!!onDragStart}
70
+ onDragStart={onDragStart}
71
+ onClick={onSelect}
72
+ title={`${session.name} - ${status} - ${session.windows} ${t.windowsShort}`}
73
+ >
74
+ <span className={`dot ${active ? 'active' : 'inactive'}`} title={status} />
75
+ <span className="name">{session.name}</span>
76
+ {reason && (
77
+ <span className={`attn-badge ${reason}`} title={attentionTitle(reason, t)}>
78
+ {attentionText(reason, t)}
79
+ </span>
80
+ )}
81
+ <span className="meta">
82
+ {session.windows} {t.windowsShort}{session.created ? ` · ${ago(session.created, nowMs, language)}` : ''}
83
+ </span>
84
+ <span className="row-actions">
85
+ <button
86
+ onClick={(e) => {
87
+ e.stopPropagation();
88
+ setDraft(session.name);
89
+ setRenaming(true);
90
+ }}
91
+ title={t.rename}
92
+ >
93
+
94
+ </button>
95
+ <button
96
+ className="danger"
97
+ onClick={(e) => {
98
+ e.stopPropagation();
99
+ if (confirm(t.killConfirm(session.name))) onKill();
100
+ }}
101
+ title={t.kill}
102
+ >
103
+
104
+ </button>
105
+ </span>
106
+ </div>
107
+ );
108
+ }
@@ -0,0 +1,197 @@
1
+ import { useEffect, useState, type DragEvent, type ReactNode } from 'react';
2
+ import type { SessionInfo, Selection } from '../types';
3
+ import type { FoldersApi } from '../folders';
4
+ import { useI18n } from '../i18n';
5
+ import { SessionRow } from './SessionRow';
6
+
7
+ const DND_TYPE = 'application/x-tmuxes';
8
+ type DragPayload = { kind: 'session'; name: string } | { kind: 'folder'; id: string };
9
+
10
+ interface Props {
11
+ targetId: string;
12
+ sessions: SessionInfo[];
13
+ folders: FoldersApi;
14
+ selection: Selection | null;
15
+ nowMs: number;
16
+ select: (sel: Selection | null) => void;
17
+ onRename: (oldName: string, newName: string) => void;
18
+ onKill: (name: string) => void;
19
+ }
20
+
21
+ export function SessionTree({ targetId, sessions, folders, selection, nowMs, select, onRename, onKill }: Props) {
22
+ const { t } = useI18n();
23
+ const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
24
+ const [renamingId, setRenamingId] = useState<string | null>(null);
25
+ const [renameDraft, setRenameDraft] = useState('');
26
+ const [dragOver, setDragOver] = useState<string | null>(null);
27
+
28
+ // Drop folder ids that no longer exist from the collapsed set.
29
+ useEffect(() => {
30
+ setCollapsed((prev) => {
31
+ const valid = new Set(folders.folders.map((f) => f.id));
32
+ const next = new Set([...prev].filter((id) => valid.has(id)));
33
+ return next.size === prev.size ? prev : next;
34
+ });
35
+ }, [folders.folders]);
36
+
37
+ const toggleCollapse = (id: string) =>
38
+ setCollapsed((prev) => {
39
+ const next = new Set(prev);
40
+ next.has(id) ? next.delete(id) : next.add(id);
41
+ return next;
42
+ });
43
+
44
+ const startDrag = (payload: DragPayload) => (e: DragEvent) => {
45
+ e.dataTransfer.setData(DND_TYPE, JSON.stringify(payload));
46
+ e.dataTransfer.effectAllowed = 'move';
47
+ };
48
+
49
+ const allowDrop = (e: DragEvent) => {
50
+ if (e.dataTransfer.types.includes(DND_TYPE)) {
51
+ e.preventDefault();
52
+ e.dataTransfer.dropEffect = 'move';
53
+ }
54
+ };
55
+
56
+ const dropInto = (folderId: string | null) => (e: DragEvent) => {
57
+ e.preventDefault();
58
+ e.stopPropagation();
59
+ setDragOver(null);
60
+ const raw = e.dataTransfer.getData(DND_TYPE);
61
+ if (!raw) return;
62
+ let payload: DragPayload;
63
+ try {
64
+ payload = JSON.parse(raw) as DragPayload;
65
+ } catch {
66
+ return;
67
+ }
68
+ if (payload.kind === 'session') folders.moveSession(payload.name, folderId);
69
+ else folders.moveFolder(payload.id, folderId);
70
+ };
71
+
72
+ const commitFolderRename = () => {
73
+ if (renamingId && renameDraft.trim()) folders.renameFolder(renamingId, renameDraft.trim());
74
+ setRenamingId(null);
75
+ };
76
+
77
+ function renderLevel(parentId: string | null, depth: number): ReactNode {
78
+ const subFolders = folders.folders.filter((f) => f.parentId === parentId);
79
+ const subSessions = sessions.filter((s) => folders.folderOf(s.name) === parentId);
80
+ return (
81
+ <>
82
+ {subFolders.map((f) => {
83
+ const isCollapsed = collapsed.has(f.id);
84
+ return (
85
+ <div
86
+ key={f.id}
87
+ className={`folder ${dragOver === f.id ? 'drop' : ''}`}
88
+ onDragOver={(e) => {
89
+ allowDrop(e);
90
+ e.stopPropagation();
91
+ setDragOver(f.id);
92
+ }}
93
+ onDragLeave={() => setDragOver((p) => (p === f.id ? null : p))}
94
+ onDrop={dropInto(f.id)}
95
+ >
96
+ {renamingId === f.id ? (
97
+ <div className="folder-head" style={{ paddingLeft: 8 + depth * 14 }}>
98
+ <input
99
+ autoFocus
100
+ value={renameDraft}
101
+ onChange={(e) => setRenameDraft(e.target.value)}
102
+ onKeyDown={(e) => {
103
+ if (e.key === 'Enter') commitFolderRename();
104
+ else if (e.key === 'Escape') setRenamingId(null);
105
+ }}
106
+ onBlur={commitFolderRename}
107
+ />
108
+ </div>
109
+ ) : (
110
+ <div
111
+ className="folder-head"
112
+ style={{ paddingLeft: 8 + depth * 14 }}
113
+ draggable
114
+ onDragStart={startDrag({ kind: 'folder', id: f.id })}
115
+ onClick={() => toggleCollapse(f.id)}
116
+ >
117
+ <span className="caret">{isCollapsed ? '▸' : '▾'}</span>
118
+ <span className="folder-name" title={f.name}>
119
+ {isCollapsed ? '📁' : '📂'} {f.name}
120
+ </span>
121
+ <span className="folder-actions">
122
+ <button
123
+ title={t.newSubfolder}
124
+ onClick={(e) => {
125
+ e.stopPropagation();
126
+ folders.addFolder(f.id, t.folderDefaultName);
127
+ setCollapsed((p) => {
128
+ const n = new Set(p);
129
+ n.delete(f.id);
130
+ return n;
131
+ });
132
+ }}
133
+ >
134
+
135
+ </button>
136
+ <button
137
+ title={t.renameFolder}
138
+ onClick={(e) => {
139
+ e.stopPropagation();
140
+ setRenameDraft(f.name);
141
+ setRenamingId(f.id);
142
+ }}
143
+ >
144
+
145
+ </button>
146
+ <button
147
+ className="danger"
148
+ title={t.deleteFolder}
149
+ onClick={(e) => {
150
+ e.stopPropagation();
151
+ folders.deleteFolder(f.id);
152
+ }}
153
+ >
154
+
155
+ </button>
156
+ </span>
157
+ </div>
158
+ )}
159
+ {!isCollapsed && <div className="folder-children">{renderLevel(f.id, depth + 1)}</div>}
160
+ </div>
161
+ );
162
+ })}
163
+ {subSessions.map((s) => (
164
+ <SessionRow
165
+ key={s.name}
166
+ targetId={targetId}
167
+ session={s}
168
+ nowMs={nowMs}
169
+ depth={depth}
170
+ selected={selection?.targetId === targetId && selection.session === s.name}
171
+ onSelect={() => select({ targetId, session: s.name })}
172
+ onRename={(next) => onRename(s.name, next)}
173
+ onKill={() => onKill(s.name)}
174
+ onDragStart={startDrag({ kind: 'session', name: s.name })}
175
+ />
176
+ ))}
177
+ </>
178
+ );
179
+ }
180
+
181
+ const empty = folders.folders.length === 0 && sessions.length === 0;
182
+
183
+ return (
184
+ <div
185
+ className={`tree ${dragOver === '__root__' ? 'drop-root' : ''}`}
186
+ onDragOver={(e) => {
187
+ allowDrop(e);
188
+ setDragOver('__root__');
189
+ }}
190
+ onDragLeave={() => setDragOver((p) => (p === '__root__' ? null : p))}
191
+ onDrop={dropInto(null)}
192
+ >
193
+ {empty && <div className="empty">{t.noSessions}</div>}
194
+ {renderLevel(null, 0)}
195
+ </div>
196
+ );
197
+ }
@@ -0,0 +1,122 @@
1
+ import { useState } from 'react';
2
+ import { FONT_LIMITS, useSettings, type Language, type Settings } from '../settings';
3
+ import { useI18n } from '../i18n';
4
+
5
+ function Stepper({
6
+ label,
7
+ value,
8
+ smaller,
9
+ larger,
10
+ onChange,
11
+ }: {
12
+ label: string;
13
+ value: number;
14
+ smaller: string;
15
+ larger: string;
16
+ onChange: (n: number) => void;
17
+ }) {
18
+ return (
19
+ <div className="stepper">
20
+ <span className="stepper-label">{label}</span>
21
+ <div className="stepper-controls">
22
+ <button onClick={() => onChange(value - 1)} disabled={value <= FONT_LIMITS.min} aria-label={smaller}>
23
+
24
+ </button>
25
+ <span className="stepper-value">{value}px</span>
26
+ <button onClick={() => onChange(value + 1)} disabled={value >= FONT_LIMITS.max} aria-label={larger}>
27
+ +
28
+ </button>
29
+ </div>
30
+ </div>
31
+ );
32
+ }
33
+
34
+ function Toggle({
35
+ label,
36
+ checked,
37
+ onChange,
38
+ }: {
39
+ label: string;
40
+ checked: boolean;
41
+ onChange: (v: boolean) => void;
42
+ }) {
43
+ return (
44
+ <label className="toggle">
45
+ <input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
46
+ <span className="toggle-label">{label}</span>
47
+ </label>
48
+ );
49
+ }
50
+
51
+ export function SettingsButton() {
52
+ const { settings, setSetting, reset } = useSettings();
53
+ const { t } = useI18n();
54
+ const [open, setOpen] = useState(false);
55
+
56
+ const step =
57
+ (key: keyof Pick<Settings, 'sidebarFontSize' | 'terminalFontSize' | 'viewerFontSize'>) =>
58
+ (n: number) =>
59
+ setSetting(key, n);
60
+
61
+ return (
62
+ <div className="settings">
63
+ <button className="settings-gear" onClick={() => setOpen((v) => !v)} title={t.settings}>
64
+ ⚙ {t.settings}
65
+ </button>
66
+ {open && (
67
+ <>
68
+ <div className="settings-backdrop" onClick={() => setOpen(false)} />
69
+ <div className="settings-panel" role="dialog">
70
+ <div className="settings-title">{t.language}</div>
71
+ <select
72
+ value={settings.language}
73
+ onChange={(e) => setSetting('language', e.target.value as Language)}
74
+ >
75
+ <option value="zh">{t.chinese}</option>
76
+ <option value="en">{t.english}</option>
77
+ </select>
78
+ <div className="settings-title">{t.fontSizes}</div>
79
+ <Stepper
80
+ label={t.sidebar}
81
+ value={settings.sidebarFontSize}
82
+ smaller={t.smaller}
83
+ larger={t.larger}
84
+ onChange={step('sidebarFontSize')}
85
+ />
86
+ <Stepper
87
+ label={t.terminal}
88
+ value={settings.terminalFontSize}
89
+ smaller={t.smaller}
90
+ larger={t.larger}
91
+ onChange={step('terminalFontSize')}
92
+ />
93
+ <Stepper
94
+ label={t.fileViewer}
95
+ value={settings.viewerFontSize}
96
+ smaller={t.smaller}
97
+ larger={t.larger}
98
+ onChange={step('viewerFontSize')}
99
+ />
100
+ <div className="settings-title">{t.notifications}</div>
101
+ <Toggle
102
+ label={t.alertWhenAgent}
103
+ checked={settings.notifyAttention}
104
+ onChange={(v) => setSetting('notifyAttention', v)}
105
+ />
106
+ <Toggle
107
+ label={t.playSound}
108
+ checked={settings.notifySound}
109
+ onChange={(v) => setSetting('notifySound', v)}
110
+ />
111
+ <div className="settings-actions">
112
+ <button onClick={reset}>{t.reset}</button>
113
+ <button className="primary" onClick={() => setOpen(false)}>
114
+ {t.done}
115
+ </button>
116
+ </div>
117
+ </div>
118
+ </>
119
+ )}
120
+ </div>
121
+ );
122
+ }