webtun 1.5.1 → 1.5.3

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/public/index.html CHANGED
@@ -11,37 +11,41 @@
11
11
  <link rel="icon" href="/favicon.png" type="image/png">
12
12
  <link rel="manifest" href="/manifest.json">
13
13
  <link rel="apple-touch-icon" href="/icon-192.png">
14
+ <link rel="preconnect" href="https://fonts.googleapis.com">
15
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
16
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
14
17
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
15
- <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
16
- <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
17
- <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-search@0.15.0/lib/addon-search.min.js"></script>
18
- <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
19
- <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-unicode11@0.7.0/lib/addon-unicode11.min.js"></script>
20
- <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.1/lib/addon-webgl.min.js"></script>
21
- <script src="https://cdn.jsdelivr.net/npm/marked@15.0.7/marked.min.js"></script>
22
- <script src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js"></script>
18
+ <script defer src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
19
+ <script defer src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
20
+ <script defer src="https://cdn.jsdelivr.net/npm/@xterm/addon-search@0.15.0/lib/addon-search.min.js"></script>
21
+ <script defer src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
22
+ <script defer src="https://cdn.jsdelivr.net/npm/@xterm/addon-unicode11@0.7.0/lib/addon-unicode11.min.js"></script>
23
+ <script defer src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
24
+ <script defer src="https://cdn.jsdelivr.net/npm/marked@15.0.7/marked.min.js"></script>
25
+ <script defer src="https://cdn.jsdelivr.net/npm/dompurify@3.2.4/dist/purify.min.js"></script>
23
26
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/lib/codemirror.min.css">
24
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/lib/codemirror.min.js"></script>
25
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/addon/edit/matchbrackets.min.js"></script>
26
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/javascript/javascript.min.js"></script>
27
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/python/python.min.js"></script>
28
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/htmlmixed/htmlmixed.min.js"></script>
29
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/xml/xml.min.js"></script>
30
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/css/css.min.js"></script>
31
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/clike/clike.min.js"></script>
32
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/shell/shell.min.js"></script>
33
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/markdown/markdown.min.js"></script>
34
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/yaml/yaml.min.js"></script>
35
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/sql/sql.min.js"></script>
36
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/go/go.min.js"></script>
37
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/addon/mode/simple.min.js"></script>
38
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/rust/rust.min.js"></script>
39
- <script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/properties/properties.min.js"></script>
27
+ <script defer src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/lib/codemirror.min.js"></script>
28
+ <script defer src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/addon/edit/matchbrackets.min.js"></script>
29
+ <script defer src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/javascript/javascript.min.js"></script>
30
+ <script defer src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/python/python.min.js"></script>
31
+ <script defer src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/htmlmixed/htmlmixed.min.js"></script>
32
+ <script defer src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/xml/xml.min.js"></script>
33
+ <script defer src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/css/css.min.js"></script>
34
+ <script defer src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/clike/clike.min.js"></script>
35
+ <script defer src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/shell/shell.min.js"></script>
36
+ <script defer src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/markdown/markdown.min.js"></script>
37
+ <script defer src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/yaml/yaml.min.js"></script>
38
+ <script defer src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/sql/sql.min.js"></script>
39
+ <script defer src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/go/go.min.js"></script>
40
+ <script defer src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/addon/mode/simple.min.js"></script>
41
+ <script defer src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/rust/rust.min.js"></script>
42
+ <script defer src="https://cdn.jsdelivr.net/npm/codemirror@5.65.18/mode/properties/properties.min.js"></script>
40
43
  <style>
41
44
  /* ═══════════════════════════════════════════════════════
42
45
  CSS VARIABLES / THEMES
43
46
  ═══════════════════════════════════════════════════════ */
44
47
  :root {
48
+ color-scheme: dark;
45
49
  --bg: #1a1b26;
46
50
  --bg2: #16161e;
47
51
  --bg3: #24283b;
@@ -49,6 +53,7 @@
49
53
  --border: #3b3f52;
50
54
  --fg: #c0caf5;
51
55
  --fg2: #787c99;
56
+ --fg3: #565f89;
52
57
  --accent: #7aa2f7;
53
58
  --accent2: #bb9af7;
54
59
  --green: #9ece6a;
@@ -74,43 +79,65 @@
74
79
  --btn-sm: 26px;
75
80
  --btn-md: 32px;
76
81
  --btn-lg: 36px;
82
+ --z-header: 10;
83
+ --z-drawer: 15;
84
+ --z-popover: 25;
85
+ --z-sidebar: 30;
86
+ --z-drop: 50;
87
+ --z-overlay: 400;
88
+ --z-toast: 450;
89
+ --z-menu: 500;
77
90
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
78
91
  --transition-fast: 0.15s;
79
92
  --transition-normal: 0.2s;
80
93
  --transition-slow: 0.3s;
81
- --font: 'SF Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
94
+ --font: 'JetBrains Mono', 'SF Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace;
95
+ --font-ui: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
96
+ }
97
+ [data-theme="tokyonight"] {
98
+ color-scheme: dark;
99
+ --bg: #1a1b26; --bg2: #16161e; --bg3: #24283b; --bg4: #2a2d3e;
100
+ --border: #3b3f52; --fg: #c0caf5; --fg2: #787c99; --fg3: #565f89;
101
+ --accent: #7aa2f7; --accent2: #bb9af7; --green: #9ece6a;
102
+ --red: #f7768e; --yellow: #e0af68; --cyan: #7dcfff;
103
+ --shadow-color: rgba(0,0,0,0.6);
82
104
  }
83
105
  [data-theme="light"] {
106
+ color-scheme: light;
84
107
  --bg: #f9f9fb; --bg2: #eeeef2; --bg3: #e2e2e8; --bg4: #d5d5de;
85
- --border: #c8c8d4; --fg: #2a2a3a; --fg2: #3a3a5a;
86
- --accent: #4060d0; --accent2: #8050c0; --green: #3a8a2a;
87
- --red: #c03050; --yellow: #a07010; --cyan: #2080a0;
108
+ --border: #c8c8d4; --fg: #1f1f30; --fg2: #2b2b45; --fg3: #5d5d78;
109
+ --accent: #4060d0; --accent2: #8050c0; --green: #2f7d1f;
110
+ --red: #c03050; --yellow: #9a6a00; --cyan: #1e7590;
88
111
  --shadow-color: rgba(0,0,0,0.12);
89
112
  }
90
113
  [data-theme="solarized"] {
114
+ color-scheme: dark;
91
115
  --bg: #002b36; --bg2: #073642; --bg3: #083f4d; --bg4: #0a4a5a;
92
- --border: #1a5a6a; --fg: #93a1a1; --fg2: #586e75;
116
+ --border: #1a5a6a; --fg: #93a1a1; --fg2: #586e75; --fg3: #486168;
93
117
  --accent: #268bd2; --accent2: #6c71c4; --green: #859900;
94
118
  --red: #dc322f; --yellow: #b58900; --cyan: #2aa198;
95
119
  --shadow-color: rgba(0,0,0,0.6);
96
120
  }
97
121
  [data-theme="gruvbox"] {
122
+ color-scheme: dark;
98
123
  --bg: #282828; --bg2: #1d2021; --bg3: #32302f; --bg4: #3c3836;
99
- --border: #504945; --fg: #ebdbb2; --fg2: #928374;
124
+ --border: #504945; --fg: #ebdbb2; --fg2: #928374; --fg3: #7c6f64;
100
125
  --accent: #83a598; --accent2: #d3869b; --green: #b8bb26;
101
126
  --red: #fb4934; --yellow: #fabd2f; --cyan: #8ec07c;
102
127
  --shadow-color: rgba(0,0,0,0.6);
103
128
  }
104
129
  [data-theme="dracula"] {
130
+ color-scheme: dark;
105
131
  --bg: #282a36; --bg2: #1e1f29; --bg3: #2f3240; --bg4: #373a50;
106
- --border: #44475a; --fg: #f8f8f2; --fg2: #6272a4;
132
+ --border: #44475a; --fg: #f8f8f2; --fg2: #6272a4; --fg3: #4f5b84;
107
133
  --accent: #bd93f9; --accent2: #ff79c6; --green: #50fa7b;
108
134
  --red: #ff5555; --yellow: #f1fa8c; --cyan: #8be9fd;
109
135
  --shadow-color: rgba(0,0,0,0.6);
110
136
  }
111
137
  [data-theme="monokai"] {
138
+ color-scheme: dark;
112
139
  --bg: #272822; --bg2: #1e1f1c; --bg3: #31322b; --bg4: #3a3c32;
113
- --border: #49483e; --fg: #f8f8f2; --fg2: #75715e;
140
+ --border: #49483e; --fg: #f8f8f2; --fg2: #75715e; --fg3: #5d5a4e;
114
141
  --accent: #a6e22e; --accent2: #ae81ff; --green: #a6e22e;
115
142
  --red: #f92672; --yellow: #e6db74; --cyan: #66d9e8;
116
143
  --shadow-color: rgba(0,0,0,0.6);
@@ -120,10 +147,10 @@
120
147
  RESET & BASE
121
148
  ═══════════════════════════════════════════════════════ */
122
149
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
123
- html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(--fg); font-family: var(--font); font-size: 13px; padding-bottom: env(safe-area-inset-bottom); scroll-behavior: smooth; touch-action: manipulation; -webkit-overflow-scrolling: touch; }
150
+ html, body { height: 100%; overflow: hidden; background: var(--bg); color: var(--fg); font-family: var(--font-ui); font-size: 13px; padding-bottom: env(safe-area-inset-bottom); touch-action: manipulation; -webkit-overflow-scrolling: touch; -webkit-tap-highlight-color: transparent; }
124
151
 
125
152
  button { background: none; border: none; cursor: pointer; color: inherit; font-family: inherit; }
126
- input, select, textarea { font-family: var(--font); background: var(--bg3); border: 1px solid var(--border); color: var(--fg); border-radius: var(--radius); padding: 6px 10px; outline: none; font-size: 16px; }
153
+ input, select, textarea { font-family: var(--font-ui); background: var(--bg3); border: 1px solid var(--border); color: var(--fg); border-radius: var(--radius); padding: 6px 10px; outline: none; font-size: 16px; }
127
154
  @media (pointer: coarse) { input, select, textarea { font-size: 16px; } }
128
155
  input:focus, select:focus, textarea:focus { border-color: var(--accent); }
129
156
  a { color: var(--accent); text-decoration: none; }
@@ -141,17 +168,38 @@ a { color: var(--accent); text-decoration: none; }
141
168
  #new-tab-btn { width: 36px; height: 36px; }
142
169
  .icon-btn { width: 40px; height: 40px; }
143
170
  .file-action-btn { width: 36px; height: 36px; }
171
+ .search-btn { width: 32px; height: 32px; }
172
+ .search-btn#search-case-btn { width: 28px; height: 28px; line-height: 28px; }
144
173
  }
145
174
  svg { display: block; }
146
175
 
176
+ /* Code/path surfaces stay monospace while the chrome uses the UI font */
177
+ .file-name,
178
+ #path-input,
179
+ #file-breadcrumb,
180
+ #editor-filename,
181
+ .tunnel-url,
182
+ .cmd-lib-item .cmd-lib-cmd,
183
+ .cmd-hist-item .cmd-hist-cmd { font-family: var(--font); }
184
+
185
+ /* Skip link for keyboard/screen-reader users */
186
+ .skip-link {
187
+ position: fixed; top: -40px; left: 8px; z-index: 9999;
188
+ background: var(--accent); color: var(--bg); padding: 6px 14px;
189
+ border-radius: 0 0 var(--radius) var(--radius); font-size: 12px;
190
+ font-family: var(--font-ui); font-weight: 600;
191
+ transition: top var(--transition-fast) ease;
192
+ }
193
+ .skip-link:focus { top: 0; }
194
+
147
195
  /* ═══════════════════════════════════════════════════════
148
196
  LAYOUT
149
197
  ═══════════════════════════════════════════════════════ */
150
198
  #app { display: flex; flex-direction: column; height: 100dvh; }
151
- #header { display: flex; align-items: center; height: var(--header-h); background: var(--bg2); border-bottom: 1px solid var(--border); padding: 0 8px; padding-top: env(safe-area-inset-top, 0px); gap: 6px; flex-shrink: 0; z-index: 10; position: relative; }
199
+ #header { display: flex; align-items: center; height: var(--header-h); background: var(--bg2); border-bottom: 1px solid var(--border); padding: 0 8px; padding-top: env(safe-area-inset-top, 0px); gap: 6px; flex-shrink: 0; z-index: var(--z-header); position: relative; }
152
200
 
153
201
  /* Upload progress bar in header */
154
- #upload-progress { position: absolute; bottom: 0; left: 0; right: 0; height: 0; background: var(--bg3); z-index: 20; overflow: visible; transition: height var(--transition-normal) ease; }
202
+ #upload-progress { position: absolute; bottom: 0; left: 0; right: 0; height: 0; background: var(--bg3); z-index: calc(var(--z-header) + 1); overflow: visible; transition: height var(--transition-normal) ease; }
155
203
  #upload-progress.active { height: 3px; }
156
204
  #upload-progress-bar { height: 100%; width: 0%; background: var(--accent); transition: width var(--transition-slow) ease; border-radius: 0 2px 2px 0; }
157
205
  #upload-progress.success #upload-progress-bar { background: var(--green); }
@@ -163,13 +211,26 @@ svg { display: block; }
163
211
  #sidebar.dragging { transition: none; }
164
212
  #sidebar.hidden { width: 0 !important; }
165
213
  #content { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; min-height: 0; position: relative; }
166
- #tab-bar { display: flex; align-items: center; height: var(--tab-h); background: var(--bg2); border-bottom: 1px solid var(--border); overflow-x: auto; overflow-y: hidden; flex-shrink: 0; gap: 2px; padding: 0 4px; mask-image: linear-gradient(to right, black calc(100% - 20px), transparent); -webkit-mask-image: linear-gradient(to right, black calc(100% - 20px), transparent); }
214
+ #tab-bar { display: flex; align-items: center; height: var(--tab-h); background: var(--bg2); border-bottom: 1px solid var(--border); flex-shrink: 0; gap: 2px; padding: 0 4px; }
215
+ #tab-scroll { display: flex; align-items: center; gap: 2px; height: 100%; flex: 1; min-width: 0; overflow-x: auto; overflow-y: hidden; mask-image: linear-gradient(to right, black calc(100% - 20px), transparent); -webkit-mask-image: linear-gradient(to right, black calc(100% - 20px), transparent); scrollbar-width: none; }
216
+ #tab-scroll::-webkit-scrollbar { height: 3px; }
167
217
  #tab-bar.drag-over, #new-tab-btn.drag-over { background: var(--accent, #00c853); opacity: 0.15; }
168
- #tab-bar::-webkit-scrollbar { height: 3px; }
169
218
  #terminals { flex: 1; position: relative; overflow: hidden; }
170
219
  .term-wrapper { position: absolute; inset: 0; padding: 4px; display: none; touch-action: pan-y; -webkit-overflow-scrolling: touch; overscroll-behavior: none; }
171
220
  .term-wrapper.active { display: flex; }
172
221
  .xterm { flex: 1; }
222
+ .term-loading {
223
+ position: absolute; inset: 0; z-index: 3;
224
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;
225
+ background: var(--bg); color: var(--fg2); font-size: 12px;
226
+ font-family: var(--font-ui); pointer-events: none;
227
+ }
228
+ .term-loading .tl-spinner {
229
+ width: 22px; height: 22px; border-radius: 50%;
230
+ border: 2px solid var(--bg4); border-top-color: var(--accent);
231
+ animation: reconnectSpin 0.8s linear infinite;
232
+ }
233
+ .term-loading.hidden { display: none; }
173
234
  .xterm-viewport { border-radius: 0; }
174
235
  .term-bell-flash { animation: bellFlash 0.15s ease-out; }
175
236
  @keyframes bellFlash { 0% { filter: brightness(1.8); } 100% { filter: brightness(1); } }
@@ -255,7 +316,7 @@ svg { display: block; }
255
316
  cursor: row-resize;
256
317
  flex-shrink: 0;
257
318
  position: relative;
258
- z-index: 10;
319
+ z-index: var(--z-header);
259
320
  background: transparent;
260
321
  transition: background var(--transition-fast);
261
322
  }
@@ -275,7 +336,7 @@ svg { display: block; }
275
336
  display: flex; flex-direction: column;
276
337
  transform: translateX(100%);
277
338
  transition: var(--transition-normal);
278
- z-index: 15;
339
+ z-index: var(--z-drawer);
279
340
  }
280
341
  #settings-panel.open {
281
342
  transform: translateX(0);
@@ -308,17 +369,27 @@ svg { display: block; }
308
369
  .keep-awake input { display: none; }
309
370
  .keep-awake-box { width: 18px; height: 18px; border: 1.5px solid var(--border); border-radius: 4px; position: relative; transition: var(--transition-normal) ease; background: var(--bg2); }
310
371
  .keep-awake:hover .keep-awake-box { border-color: var(--fg2); }
311
- .keep-awake-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--fg); position: absolute; top: 4px; left: 3px; transition: var(--transition-normal) ease; }
372
+ .keep-awake-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--fg); position: absolute; top: 4px; left: 3px; transition: left var(--transition-normal) ease; }
312
373
  .keep-awake input:checked + .keep-awake-box { border-color: var(--accent); background: var(--accent); }
313
- .keep-awake input:checked + .keep-awake-box .keep-awake-dot { background: var(--bg); left: calc(100% - 11px); animation: keepawake-bounce 0.8s ease-in-out infinite; }
314
-
315
- @keyframes keepawake-bounce {
316
- 0%, 100% { left: 3px; }
317
- 50% { left: calc(100% - 11px); }
318
- }
374
+ .keep-awake input:checked + .keep-awake-box .keep-awake-dot { background: var(--bg); left: calc(100% - 11px); }
319
375
 
320
376
  #hostname-badge { font-size: 11px; color: var(--fg2); padding: 2px 8px; background: var(--bg3); border-radius: 999px; }
377
+ #more-btn { display: none; }
378
+ #more-menu { position: absolute; top: calc(100% + 4px); right: env(safe-area-inset-right, 0px); background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 8px 32px var(--shadow-color); padding: 4px; min-width: 190px; display: none; flex-direction: column; gap: 2px; z-index: var(--z-popover); }
379
+ #more-menu.open { display: flex; }
380
+ .mm-item { display: flex; align-items: center; gap: 9px; padding: 9px 10px; font-size: 12px; font-family: var(--font); border-radius: var(--radius-sm); color: var(--fg); text-align: left; width: 100%; cursor: pointer; }
381
+ .mm-item:hover, .mm-item:focus-visible { background: var(--bg4); color: var(--fg); outline: none; }
382
+ .tunnel-row { padding: 6px 0; border-bottom: 1px solid var(--border); }
383
+ .tunnel-label { font-size: 10px; color: var(--fg2); margin-bottom: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
384
+ .tunnel-inner { display: flex; align-items: center; gap: 5px; min-width: 0; }
385
+ .tunnel-url { font-size: 11px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
386
+ .tunnel-status, .tunnel-target { font-size: 9px; padding: 1px 5px; border-radius: 4px; flex-shrink: 0; display: inline-flex; align-items: center; gap: 2px; }
387
+ .tunnel-btn { width: 22px; height: 22px; flex-shrink: 0; }
388
+ .tunnel-btn.stop { color: var(--red); }
321
389
  #conn-status { transition: background var(--transition-slow); }
390
+ .reconnect-spinner { display: inline-flex; vertical-align: -1px; margin-right: 4px; }
391
+ .reconnect-spinner svg { animation: reconnectSpin 1s linear infinite; }
392
+ @keyframes reconnectSpin { to { transform: rotate(360deg); } }
322
393
 
323
394
  /* ═══════════════════════════════════════════════════════
324
395
  TABS
@@ -341,11 +412,24 @@ svg { display: block; }
341
412
  ═══════════════════════════════════════════════════════ */
342
413
  #sidebar-header { display: flex; align-items: center; padding: 6px 8px; gap: 4px; border-bottom: 1px solid var(--border); flex-shrink: 0; flex-wrap: wrap; min-height: 40px; height: auto; }
343
414
  #sidebar-header input { flex: 1 1 60px; min-width: 40px; height: 26px; font-size: 12px; }
344
- #file-list-wrap { flex: 1; overflow-y: auto; min-height: 0; touch-action: pan-y; -webkit-overflow-scrolling: touch; }
415
+ #file-list-wrap { flex: 1; overflow-y: auto; min-height: 0; touch-action: pan-y; -webkit-overflow-scrolling: touch; position: relative; }
416
+ #ptr-indicator { position: absolute; top: -40px; left: 0; right: 0; height: 36px; display: flex; align-items: center; justify-content: center; gap: 6px; font-size: 11px; color: var(--fg2); opacity: 0; pointer-events: none; }
417
+ #ptr-indicator .ptr-arrow { display: inline-flex; }
418
+ #ptr-indicator .ptr-arrow svg { transition: transform 0.2s ease; }
419
+ #ptr-indicator.release .ptr-arrow svg { transform: rotate(180deg); }
420
+ #ptr-indicator.release .ptr-text { color: var(--accent); }
421
+ /* Loading state: dim content + indeterminate progress bar */
422
+ #file-list-wrap.loading #file-list { opacity: 0.45; transition: opacity var(--transition-fast); }
423
+ #file-list-wrap.loading::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 2px; background: var(--bg3); z-index: 5; }
424
+ #file-list-wrap.loading::after { content: ''; position: absolute; top: 0; left: -30%; width: 30%; height: 2px; background: var(--accent); border-radius: 0 2px 2px 0; z-index: 6; animation: fileListProgress 0.9s ease-in-out infinite; }
425
+ @keyframes fileListProgress { from { left: -30%; } to { left: 100%; } }
345
426
  .file-list-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 20px; color: var(--fg2); font-size: 13px; text-align: center; gap: 8px; }
346
427
  .file-list-empty svg { opacity: 0.4; }
347
- #file-breadcrumb { padding: 8px 10px; font-size: 12px; color: var(--fg2); border-bottom: 1px solid var(--border); word-break: break-all; line-height: 1.8; flex-shrink: 0; }
348
- #file-breadcrumb a { transition: background var(--transition-fast); border-radius: var(--radius-sm); padding: 4px 6px !important; }
428
+ .empty-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; }
429
+ .empty-actions .btn { font-size: 12px; padding: 4px 12px; }
430
+ #file-breadcrumb { padding: 8px 10px; font-size: 12px; color: var(--fg2); border-bottom: 1px solid var(--border); white-space: nowrap; overflow-x: auto; line-height: 1.8; flex-shrink: 0; scrollbar-width: none; }
431
+ #file-breadcrumb::-webkit-scrollbar { display: none; }
432
+ #file-breadcrumb a { transition: background var(--transition-fast); border-radius: var(--radius-sm); padding: 4px 6px !important; display: inline-block; }
349
433
  #file-breadcrumb a:hover { background: var(--bg3); }
350
434
  @media (pointer: coarse) {
351
435
  #file-breadcrumb { padding: 10px 12px; font-size: 13px; }
@@ -354,6 +438,9 @@ svg { display: block; }
354
438
  .file-item { display: flex; align-items: center; gap: 8px; padding: 5px 10px; cursor: pointer; border-bottom: 1px solid transparent; font-size: 12px; position: relative; user-select: none; }
355
439
  .file-item:hover { background: var(--bg3); }
356
440
  .file-item.selected { background: var(--bg4); }
441
+ .file-item.focused { background: var(--bg3); outline: 1px solid var(--accent); outline-offset: -1px; }
442
+ .file-item .file-ellipsis { display: flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: var(--radius-sm); color: var(--fg2); opacity: 0.45; flex-shrink: 0; }
443
+ .file-item:hover .file-ellipsis { opacity: 1; }
357
444
  .file-icon { font-size: 14px; width: 16px; text-align: center; flex-shrink: 0; }
358
445
  .file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; user-select: text; -webkit-user-select: text; }
359
446
  .file-size { font-size: 10px; color: var(--fg2); flex-shrink: 0; user-select: text; -webkit-user-select: text; }
@@ -378,7 +465,7 @@ svg { display: block; }
378
465
  /* Sidebar resize handle */
379
466
  .sidebar-resize-handle {
380
467
  position: absolute; right: 0; top: 0; bottom: 0;
381
- width: 5px; cursor: col-resize; z-index: 10;
468
+ width: 5px; cursor: col-resize; z-index: var(--z-header);
382
469
  background: transparent; transition: background var(--transition-fast); flex-shrink: 0;
383
470
  }
384
471
  .sidebar-resize-handle:hover,
@@ -432,9 +519,10 @@ svg { display: block; }
432
519
  /* ═══════════════════════════════════════════════════════
433
520
  SEARCH BAR
434
521
  ═══════════════════════════════════════════════════════ */
435
- #search-bar { position: absolute; top: 8px; right: 12px; z-index: 20; background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 8px; display: none; align-items: center; gap: 6px; box-shadow: 0 4px 16px var(--shadow-color); }
522
+ #search-bar { position: absolute; top: 8px; right: 12px; z-index: var(--z-popover); background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 8px; display: none; align-items: center; gap: 6px; box-shadow: 0 4px 16px var(--shadow-color); }
436
523
  #search-bar.open { display: flex; }
437
524
  #search-input { width: 200px; height: 26px; }
525
+ @media (max-width: 480px) { #search-bar { left: 8px; right: 8px; } #search-input { flex: 1; width: auto; min-width: 0; } }
438
526
  .search-btn { width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; border-radius: 4px; color: var(--fg2); }
439
527
  .search-btn:hover { background: var(--bg4); color: var(--fg); }
440
528
  .search-btn#search-case-btn { font-size: 11px; font-weight: bold; width: 22px; height: 22px; line-height: 22px; }
@@ -444,7 +532,7 @@ svg { display: block; }
444
532
  /* ═══════════════════════════════════════════════════════
445
533
  OVERLAY
446
534
  ═══════════════════════════════════════════════════════ */
447
- .overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 400; justify-content: center; align-items: center; backdrop-filter: blur(4px); will-change: backdrop-filter; }
535
+ .overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: var(--z-overlay); justify-content: center; align-items: center; backdrop-filter: blur(4px); will-change: backdrop-filter; }
448
536
  @media (prefers-reduced-transparency: reduce) { .overlay { backdrop-filter: none; } }
449
537
  .overlay.open { display: flex; }
450
538
  .modal { background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 24px; min-width: 320px; max-width: 90vw; max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 40px var(--shadow-color); }
@@ -456,6 +544,11 @@ svg { display: block; }
456
544
  .modal-row label { display: block; font-size: 11px; color: var(--fg2); margin-bottom: 5px; text-transform: uppercase; letter-spacing: 0.5px; }
457
545
  .modal-row input, .modal-row select { width: 100%; }
458
546
  .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 20px; }
547
+ .shortcuts-table { width: 100%; border-collapse: collapse; }
548
+ .shortcuts-table td { padding: 6px 0; font-size: 12.5px; color: var(--fg); border-bottom: 1px solid var(--border); }
549
+ .shortcuts-table td:first-child { color: var(--fg2); white-space: nowrap; padding-right: 16px; width: 1%; }
550
+ .shortcuts-table tr:last-child td { border-bottom: none; }
551
+ kbd { display: inline-block; padding: 1px 6px; font-family: var(--font); font-size: 11px; color: var(--fg); background: var(--bg1); border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 4px; }
459
552
  .btn { height: 34px; padding: 0 16px; border-radius: var(--radius); font-size: 13px; font-family: var(--font); cursor: pointer; transition: all var(--transition-fast); border: 1px solid transparent; }
460
553
  #conflict-modal.modal-sm { width: 380px; }
461
554
  #conflict-heading { font-size: 15px; margin-bottom: 8px; color: var(--fg); }
@@ -465,11 +558,22 @@ svg { display: block; }
465
558
  #conflict-actions { flex-wrap: wrap; gap: 6px; }
466
559
  #conflict-replace, #conflict-merge, #conflict-keep-both, #conflict-skip, #conflict-cancel { font-size: 12px; height: var(--btn-md); }
467
560
  .btn-primary { background: var(--accent); color: var(--bg); }
468
- .btn-primary:hover { filter: brightness(1.1); }
561
+ .btn-primary:hover { filter: brightness(1.06); }
469
562
  .btn-ghost { background: var(--bg3); color: var(--fg); border-color: var(--border); }
470
563
  .btn-ghost:hover { background: var(--bg4); }
471
564
  .btn-danger { background: var(--red); color: white; }
472
- .btn-danger:hover { filter: brightness(1.1); }
565
+ .btn-danger:hover { filter: brightness(1.06); }
566
+ .btn:not(.btn-primary):not(.btn-ghost):not(.btn-danger):hover { background: var(--bg4); }
567
+ .btn:active { filter: brightness(0.95); }
568
+ .btn.loading { opacity: 0.65; pointer-events: none; position: relative; color: transparent; }
569
+ .btn.loading::after {
570
+ content: ''; position: absolute; inset: 0; margin: auto;
571
+ width: 14px; height: 14px; border-radius: 50%;
572
+ border: 2px solid color-mix(in srgb, currentColor 30%, transparent);
573
+ border-top-color: currentColor; animation: reconnectSpin 0.7s linear infinite;
574
+ }
575
+ .field-error { display: none; color: var(--red); font-size: 11px; margin-top: 5px; line-height: 1.4; }
576
+ .field-error.show { display: block; }
473
577
 
474
578
  /* ═══════════════════════════════════════════════════════
475
579
  PIN SCREEN
@@ -488,7 +592,10 @@ svg { display: block; }
488
592
  EDITOR
489
593
  ═══════════════════════════════════════════════════════ */
490
594
  #editor-header { display: flex; align-items: center; gap: 8px; padding: 0 12px; height: var(--header-h); border-bottom: 1px solid var(--border); flex-shrink: 0; background: var(--bg2); }
491
- #editor-filename { flex: 1; color: var(--fg); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
595
+ #editor-filename-wrap { flex: 1; display: flex; align-items: center; gap: 7px; min-width: 0; }
596
+ #editor-filename { color: var(--fg); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
597
+ .editor-dirty-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--yellow); flex-shrink: 0; display: none; }
598
+ #editor-view.editor-dirty .editor-dirty-dot { display: inline-block; }
492
599
  #editor-status { font-size: 11px; color: var(--fg2); }
493
600
  #editor-area { flex: 1; overflow: hidden; position: relative; }
494
601
  #editor-preview { width: 100%; height: 100%; overflow-y: auto; padding: 12px 24px; font-size: 14px; line-height: 1.7; color: var(--fg); display: none; box-sizing: border-box; }
@@ -553,7 +660,7 @@ svg { display: block; }
553
660
  /* ═══════════════════════════════════════════════════════
554
661
  CONTEXT MENU
555
662
  ═══════════════════════════════════════════════════════ */
556
- #ctx-menu { position: fixed; background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 4px 20px var(--shadow-color); z-index: 500; display: none; min-width: 160px; overflow: hidden; max-height: calc(100vh - 16px); overflow-y: auto; }
663
+ #ctx-menu { position: fixed; background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 4px 20px var(--shadow-color); z-index: var(--z-menu); display: none; min-width: 160px; overflow: hidden; max-height: calc(100vh - 16px); overflow-y: auto; }
557
664
  @media (max-width: 360px) { #ctx-menu { min-width: 140px; max-width: calc(100vw - 16px); font-size: 11px; } }
558
665
  #ctx-menu.open { display: block; }
559
666
  .ctx-item { padding: 8px 14px; font-size: 12px; cursor: pointer; display: flex; align-items: center; gap: 8px; }
@@ -569,11 +676,16 @@ svg { display: block; }
569
676
  /* ═══════════════════════════════════════════════════════
570
677
  TOAST
571
678
  ═══════════════════════════════════════════════════════ */
572
- #toast-container { position: fixed; bottom: calc(var(--mobilekey-h) + 12px + env(safe-area-inset-bottom, 0px)); right: 16px; left: 16px; z-index: 450; display: flex; flex-direction: column; gap: 8px; align-items: flex-end; transition: bottom var(--transition-normal); }
679
+ #toast-container { position: fixed; bottom: calc(var(--mobilekey-h) + 12px + env(safe-area-inset-bottom, 0px)); right: 16px; left: 16px; z-index: var(--z-toast); display: flex; flex-direction: column; gap: 8px; align-items: flex-end; transition: bottom var(--transition-normal); }
573
680
  #toast-container.keys-hidden { bottom: calc(12px + env(safe-area-inset-bottom, 0px)); }
574
681
  #toast-container.banner-visible { bottom: calc(var(--mobilekey-h) + 60px + env(safe-area-inset-bottom, 0px)); }
575
682
  #toast-container.keys-hidden.banner-visible { bottom: 60px; }
576
- .toast { padding: 10px 16px; background: var(--bg4); border: 1px solid var(--border); border-radius: var(--radius); font-size: 12px; color: var(--fg); box-shadow: 0 4px 16px var(--shadow-color); animation: slideIn 0.2s ease; max-width: min(400px, calc(100vw - 32px)); }
683
+ .toast { padding: 10px 16px; background: var(--bg4); border: 1px solid var(--border); border-radius: var(--radius); font-size: 12px; color: var(--fg); box-shadow: 0 4px 16px var(--shadow-color); animation: slideIn 0.2s ease; max-width: min(400px, calc(100vw - 32px)); display: flex; align-items: center; gap: 8px; }
684
+ .toast svg { flex-shrink: 0; color: var(--fg2); }
685
+ .toast.success svg { color: var(--green); }
686
+ .toast.error svg { color: var(--red); }
687
+ .toast.warning svg { color: var(--yellow); }
688
+ .toast.info svg { color: var(--accent); }
577
689
  .toast.success { border-left: 3px solid var(--green); }
578
690
  .toast.error { border-left: 3px solid var(--red); }
579
691
  .toast.warning { border-left: 3px solid var(--yellow); }
@@ -583,7 +695,7 @@ svg { display: block; }
583
695
  /* ═══════════════════════════════════════════════════════
584
696
  DRAG & DROP
585
697
  ═══════════════════════════════════════════════════════ */
586
- #drop-overlay { position: fixed; inset: 0; background: color-mix(in srgb, var(--accent) 15%, transparent); border: 3px dashed var(--accent); z-index: 50; display: none; align-items: center; justify-content: center; font-size: 24px; color: var(--accent); }
698
+ #drop-overlay { position: fixed; inset: 0; background: color-mix(in srgb, var(--accent) 15%, transparent); border: 3px dashed var(--accent); z-index: var(--z-drop); display: none; align-items: center; justify-content: center; font-size: 24px; color: var(--accent); }
587
699
  #drop-overlay.active { display: flex; }
588
700
  @media (hover: none) and (pointer: coarse) { #drop-overlay { display: none !important; } }
589
701
 
@@ -649,7 +761,7 @@ svg { display: block; }
649
761
  COMMAND LIBRARY BUBBLE
650
762
  ═══════════════════════════════════════════════════════ */
651
763
  #cmd-lib-panel {
652
- position: absolute; top: 42px; right: 12px; z-index: 25;
764
+ position: absolute; top: 42px; right: 12px; z-index: var(--z-popover);
653
765
  width: 380px; max-height: 480px;
654
766
  background: var(--bg2); border: 1px solid var(--border);
655
767
  border-radius: var(--radius-lg);
@@ -821,12 +933,14 @@ svg { display: block; }
821
933
  @media (max-width: 768px) {
822
934
  #mobile-keys { display: flex; }
823
935
  .sidebar-resize-handle { display: none; }
824
- #sidebar { position: absolute; left: env(safe-area-inset-left, 0px); top: var(--header-h); bottom: 0; z-index: 30; width: var(--sidebar-w); transform: translateX(-100%); transition: var(--transition-normal); }
936
+ #sidebar { position: absolute; left: env(safe-area-inset-left, 0px); top: var(--header-h); bottom: 0; z-index: var(--z-sidebar); width: var(--sidebar-w); transform: translateX(-100%); transition: var(--transition-normal); }
825
937
  #sidebar.mobile-open { transform: translateX(0); }
826
938
  #sidebar.hidden { transform: translateX(-100%); width: var(--sidebar-w); }
827
- #settings-panel { position: fixed; right: env(safe-area-inset-right, 0px); top: var(--header-h); bottom: 0; z-index: 30; width: var(--settings-w); border-left: 1px solid var(--border); transform: translateX(100%); transition: var(--transition-normal); }
939
+ #settings-panel { position: fixed; right: env(safe-area-inset-right, 0px); top: var(--header-h); bottom: 0; z-index: var(--z-sidebar); width: var(--settings-w); border-left: 1px solid var(--border); transform: translateX(100%); transition: var(--transition-normal); }
828
940
  #settings-panel.open { transform: translateX(0); }
829
- #hostname-badge { display: none; }
941
+ #hostname-badge { display: inline-flex; max-width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
942
+ #more-btn { display: flex; }
943
+ #cmd-lib-toggle, #system-stats-btn, #tilesBtn { display: none; }
830
944
  #search-input { width: 200px; }
831
945
  .modal { min-width: unset; width: 95vw; }
832
946
  .sys-grid { grid-template-columns: 1fr; }
@@ -859,6 +973,7 @@ svg { display: block; }
859
973
  </style>
860
974
  </head>
861
975
  <body data-theme="light">
976
+ <a class="skip-link" href="#content">Skip to content</a>
862
977
  <script>
863
978
  // Safe localStorage wrapper — degrades gracefully when Tracking Prevention blocks storage
864
979
  const safeStorage = {
@@ -881,7 +996,7 @@ svg { display: block; }
881
996
  <!-- PIN Screen -->
882
997
  <div id="pin-screen">
883
998
  <div id="pin-box">
884
- <h1 style="display:flex;align-items:center;justify-content:center;gap:6px"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="4" width="20" height="16" rx="2"/><line x1="6" y1="8" x2="10" y2="8"/><line x1="6" y1="12" x2="10" y2="12"/><line x1="6" y1="16" x2="8" y2="16"/><line x1="12" y1="8" x2="18" y2="8"/><line x1="12" y1="12" x2="18" y2="12"/><line x1="12" y1="16" x2="16" y2="16"/><line x1="10" y1="16" x2="10" y2="16"/></svg>WebTun</h1>
999
+ <h1 style="display:flex;align-items:center;justify-content:center;gap:6px"><svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><line x1="6" y1="8" x2="10" y2="8"/><line x1="6" y1="12" x2="10" y2="12"/><line x1="6" y1="16" x2="8" y2="16"/><line x1="12" y1="8" x2="18" y2="8"/><line x1="12" y1="12" x2="18" y2="12"/><line x1="12" y1="16" x2="16" y2="16"/><line x1="10" y1="16" x2="10" y2="16"/></svg>WebTun</h1>
885
1000
  <p>Enter PIN to continue</p>
886
1001
  <input id="pin-input" type="password" placeholder="••••" autocomplete="off" maxlength="32" inputmode="numeric" aria-label="PIN code">
887
1002
  <div id="pin-error" role="alert"></div>
@@ -894,13 +1009,13 @@ svg { display: block; }
894
1009
  <!-- Main App -->
895
1010
  <div id="app" style="display:none">
896
1011
  <!-- Header -->
897
- <div id="header">
898
- <span id="conn-status" style="width:8px;height:8px;border-radius:50%;background:var(--green);flex-shrink:0" title="Connected"></span>
899
- <span id="logo" style="display:flex;align-items:center;gap:4px"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="4" width="20" height="16" rx="2"/><line x1="6" y1="8" x2="10" y2="8"/><line x1="6" y1="12" x2="10" y2="12"/><line x1="12" y1="8" x2="18" y2="8"/><line x1="12" y1="12" x2="18" y2="12"/></svg>WebTun</span>
1012
+ <div id="header" role="banner">
1013
+ <span id="conn-status" role="status" aria-label="Connected" style="width:8px;height:8px;border-radius:50%;background:var(--green);flex-shrink:0" title="Connected"></span>
1014
+ <span id="logo" style="display:flex;align-items:center;gap:4px"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><line x1="6" y1="8" x2="10" y2="8"/><line x1="6" y1="12" x2="10" y2="12"/><line x1="12" y1="8" x2="18" y2="8"/><line x1="12" y1="12" x2="18" y2="12"/></svg>WebTun</span>
900
1015
  <span id="hostname-badge">—</span>
901
1016
  <div class="header-spacer"></div>
902
1017
  <label id="keep-awake-toggle" class="keep-awake" title="Keep Screen Awake">
903
- <input type="checkbox" id="keep-awake-cb" onchange="toggleKeepAwake(this.checked)">
1018
+ <input type="checkbox" id="keep-awake-cb" aria-label="Keep screen awake" onchange="toggleKeepAwake(this.checked)">
904
1019
  <span class="keep-awake-box">
905
1020
  <span class="keep-awake-dot"></span>
906
1021
  </span>
@@ -920,40 +1035,54 @@ svg { display: block; }
920
1035
  <button class="icon-btn" id="tilesBtn" aria-label="Tiles Mode" title="Tiles Mode" onclick="toggleTiles()">
921
1036
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
922
1037
  </button>
1038
+ <button class="icon-btn" id="more-btn" aria-label="More options" title="More options" onclick="toggleMoreMenu()">
1039
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><circle cx="12" cy="5" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="12" cy="19" r="1.7"/></svg>
1040
+ </button>
923
1041
  <button class="icon-btn" aria-label="Settings" title="Settings" onclick="openSettings()">
924
1042
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
925
1043
  </button>
1044
+ <div id="more-menu" role="menu">
1045
+ <button class="mm-item" role="menuitem" onclick="closeMoreMenu();openSystemStats()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="4" rx="1"/><rect x="2" y="10" width="20" height="4" rx="1"/><rect x="2" y="18" width="20" height="4" rx="1"/><circle cx="6" cy="4" r="1" fill="currentColor"/><circle cx="6" cy="12" r="1" fill="currentColor"/><circle cx="6" cy="20" r="1" fill="currentColor"/></svg> System Stats</button>
1046
+ <button class="mm-item" role="menuitem" onclick="closeMoreMenu();toggleTiles()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg> Tiles Mode</button>
1047
+ <button class="mm-item" role="menuitem" onclick="closeMoreMenu();toggleCmdLib()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg> Command Library</button>
1048
+ <button class="mm-item" role="menuitem" onclick="closeMoreMenu();toggleSearch()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg> Search</button>
1049
+ <button class="mm-item" role="menuitem" onclick="closeMoreMenu();openShortcuts()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 8h6M12 8v8"/><path d="m7 12 10-4"/><path d="m7 12 10 4"/></svg> Keyboard Shortcuts</button>
1050
+ </div>
926
1051
  <div id="upload-progress"><div id="upload-progress-bar"></div><span id="upload-progress-text"></span></div>
927
1052
  </div>
928
- <div id="reconnect-banner" style="display:none;padding:4px 12px;font-size:11px;background:var(--accent);color:#fff;text-align:center;flex-shrink:0">⚠️ Reconnecting…</div>
1053
+ <div id="reconnect-banner" style="display:none;padding:4px 12px;font-size:11px;background:var(--accent);color:#fff;text-align:center;flex-shrink:0"><span class="reconnect-spinner"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></span> Reconnecting…</div>
929
1054
 
930
1055
  <!-- Main -->
931
1056
  <div id="main">
932
1057
  <!-- Sidebar -->
933
- <div id="sidebar">
1058
+ <div id="sidebar" role="navigation" aria-label="File explorer">
934
1059
  <div id="sidebar-header">
935
1060
  <button class="icon-btn" style="width:24px;height:24px" aria-label="Back" title="Back" onclick="navigateBack()" id="back-btn">
936
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
1061
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
937
1062
  </button>
938
1063
  <button class="icon-btn" style="width:24px;height:24px" aria-label="Forward" title="Forward" onclick="navigateForward()" id="fwd-btn">
939
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
1064
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
940
1065
  </button>
941
1066
  <button class="icon-btn" style="width:24px;height:24px" aria-label="Go to terminal directory" title="Go to terminal directory" onclick="goToTerminalDir()" id="term-dir-btn">
942
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
1067
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
943
1068
  </button>
944
1069
  <input id="path-input" type="text" placeholder="Path…" aria-label="File path" onkeydown="if(event.key==='Enter')navigateTo(this.value)">
945
1070
  <button class="icon-btn" style="width:24px;height:24px" aria-label="Go up" title="Go up" onclick="navigateUp()">
946
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
1071
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
947
1072
  </button>
948
1073
  <button class="icon-btn" style="width:24px;height:24px" aria-label="Refresh" title="Refresh" onclick="refreshFiles()">
949
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
1074
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
950
1075
  </button>
951
1076
  <button class="icon-btn" style="width:24px;height:24px" aria-label="Toggle select mode" title="Toggle select mode" onclick="toggleSelectMode()">
952
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="9 12 11 14 15 10"/></svg>
1077
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="9 12 11 14 15 10"/></svg>
953
1078
  </button>
954
1079
  </div>
955
1080
  <div id="file-breadcrumb">/</div>
956
1081
  <div id="file-list-wrap">
1082
+ <div id="ptr-indicator" aria-hidden="true">
1083
+ <span class="ptr-arrow"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg></span>
1084
+ <span class="ptr-text">Pull to refresh</span>
1085
+ </div>
957
1086
  <div id="file-list"></div>
958
1087
  </div>
959
1088
  <div id="select-actions" style="display:none;padding:8px 10px;border-top:1px solid var(--border);background:var(--bg2);gap:4px;flex-wrap:wrap">
@@ -991,10 +1120,11 @@ svg { display: block; }
991
1120
  </div>
992
1121
 
993
1122
  <!-- Content -->
994
- <div id="content">
1123
+ <div id="content" role="main">
995
1124
  <div id="tab-bar">
996
- <button id="new-tab-btn" onclick="newTab()" title="New terminal">
997
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
1125
+ <div id="tab-scroll"></div>
1126
+ <button id="new-tab-btn" onclick="newTab()" title="New terminal" aria-label="New terminal">
1127
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
998
1128
  </button>
999
1129
  </div>
1000
1130
  <div id="editor-split-area">
@@ -1003,16 +1133,16 @@ svg { display: block; }
1003
1133
  <input id="search-input" type="text" placeholder="Search…" oninput="doSearch()" onkeydown="searchKeydown(event)">
1004
1134
  <span id="search-results"></span>
1005
1135
  <button class="search-btn" title="Prev (Shift+Enter)" onclick="searchPrev()">
1006
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="18 15 12 9 6 15"/></svg>
1136
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"/></svg>
1007
1137
  </button>
1008
1138
  <button class="search-btn" title="Next (Enter)" onclick="searchNext()">
1009
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>
1139
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
1010
1140
  </button>
1011
1141
  <button class="search-btn" id="search-case-btn" title="Match Case" onclick="toggleSearchCase()">
1012
1142
  Aa
1013
1143
  </button>
1014
1144
  <button class="search-btn" title="Close (Esc)" onclick="closeSearch()">
1015
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1145
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1016
1146
  </button>
1017
1147
  </div>
1018
1148
  </div>
@@ -1025,7 +1155,7 @@ svg { display: block; }
1025
1155
  Command Library
1026
1156
  </h3>
1027
1157
  <button class="icon-btn" style="width:24px;height:24px" onclick="toggleCmdLib()" title="Close">
1028
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1158
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1029
1159
  </button>
1030
1160
  </div>
1031
1161
  <div class="cmd-lib-tabs">
@@ -1057,7 +1187,7 @@ svg { display: block; }
1057
1187
  <button class="icon-btn" aria-label="Close editor" onclick="closeEditor()" title="Close editor">
1058
1188
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1059
1189
  </button>
1060
- <span id="editor-filename">—</span>
1190
+ <span id="editor-filename-wrap"><span class="editor-dirty-dot" id="editor-dirty-dot" aria-label="Unsaved changes"></span><span id="editor-filename">—</span></span>
1061
1191
  <span id="editor-status"></span>
1062
1192
  <button class="btn" id="md-preview-toggle" style="height:26px;padding:0 12px;font-size:12px;display:none" onclick="toggleMdPreview()">Preview</button>
1063
1193
  <button class="icon-btn" id="preview-refresh-btn" aria-label="Refresh preview" title="Refresh preview (Ctrl+Shift+R)" onclick="refreshPreview()" style="display:none">
@@ -1066,7 +1196,7 @@ svg { display: block; }
1066
1196
  <button class="icon-btn active" id="editor-split-toggle" aria-label="Toggle split orientation" onclick="toggleEditorSplit()" title="Switch to horizontal split">
1067
1197
  <svg id="split-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="12" x2="21" y2="12"/></svg>
1068
1198
  </button>
1069
- <button class="btn btn-primary" style="height:26px;padding:0 12px;font-size:12px" onclick="saveFile()">Save</button>
1199
+ <button class="btn btn-primary" id="editor-save-btn" style="height:26px;padding:0 12px;font-size:12px" onclick="saveFile()">Save</button>
1070
1200
  </div>
1071
1201
  <div id="editor-area">
1072
1202
  <textarea id="editor-textarea" spellcheck="false"></textarea>
@@ -1077,16 +1207,16 @@ svg { display: block; }
1077
1207
 
1078
1208
  <!-- Mobile key bar -->
1079
1209
  <div id="mobile-keys">
1080
- <button class="mkey sel-toggle" onclick="toggleTermSelect()" title="Toggle selection mode" id="sel-toggle-btn">⎚</button>
1210
+ <button class="mkey sel-toggle" onclick="toggleTermSelect()" title="Toggle selection mode" id="sel-toggle-btn" aria-label="Toggle selection mode"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="5" width="14" height="14" rx="2"/><line x1="5" y1="12" x2="19" y2="12"/><line x1="12" y1="5" x2="12" y2="19"/></svg></button>
1081
1211
  <span class="mkey-sep"></span>
1082
1212
  <button class="mkey" onclick="sendKey('\x1b')" title="Escape">ESC</button>
1083
1213
  <span class="mkey-sep"></span>
1084
1214
  <button class="mkey" onclick="sendKey('\t')" title="Tab">TAB</button>
1085
1215
  <span class="mkey-sep"></span>
1086
- <button class="mkey" onclick="sendKey('\x1b[A')" title="Arrow Up">▲</button>
1087
- <button class="mkey" onclick="sendKey('\x1b[B')" title="Arrow Down">▼</button>
1088
- <button class="mkey" onclick="sendKey('\x1b[D')" title="Arrow Left">◀</button>
1089
- <button class="mkey" onclick="sendKey('\x1b[C')" title="Arrow Right">▶</button>
1216
+ <button class="mkey" onclick="sendKey('\x1b[A')" title="Arrow Up"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"/></svg></button>
1217
+ <button class="mkey" onclick="sendKey('\x1b[B')" title="Arrow Down"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg></button>
1218
+ <button class="mkey" onclick="sendKey('\x1b[D')" title="Arrow Left"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg></button>
1219
+ <button class="mkey" onclick="sendKey('\x1b[C')" title="Arrow Right"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></button>
1090
1220
  <span class="mkey-sep"></span>
1091
1221
  <button class="mkey" onclick="sendKey('\x01')" title="Ctrl+A (Home)">^A</button>
1092
1222
  <button class="mkey" onclick="sendKey('\x03')" title="Ctrl+C (Interrupt)">^C</button>
@@ -1095,7 +1225,7 @@ svg { display: block; }
1095
1225
  <button class="mkey" onclick="sendKey('\x0c')" title="Ctrl+L (Clear)">^L</button>
1096
1226
  <span class="mkey-sep"></span>
1097
1227
  <button class="mkey wide" onclick="sendKey('\x1b[3~')" title="Delete">DEL</button>
1098
- <button class="mkey" onclick="sendKey('\x7f')" title="Backspace">⌫</button>
1228
+ <button class="mkey" onclick="sendKey('\x7f')" title="Backspace"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 5H9l-7 7 7 7h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1Z"/><line x1="18" y1="9" x2="12" y2="15"/><line x1="12" y1="9" x2="18" y2="15"/></svg></button>
1099
1229
  <span class="mkey-sep"></span>
1100
1230
  <button class="mkey" onclick="sendKey('\x1b[H')" title="Home">Home</button>
1101
1231
  <button class="mkey" onclick="sendKey('\x1b[F')" title="End">End</button>
@@ -1105,7 +1235,7 @@ svg { display: block; }
1105
1235
  <button class="mkey" onclick="sendKey('\x1bOQ')" title="F2">F2</button>
1106
1236
  <button class="mkey" onclick="sendKey('\x1b[15~')" title="F5">F5</button>
1107
1237
  <span class="mkey-sep"></span>
1108
- <button class="mkey" onclick="toggleShift()" title="Toggle Shift (latches for next key)" id="shift-toggle-btn">⇧</button>
1238
+ <button class="mkey" onclick="toggleShift()" title="Toggle Shift (latches for next key)" id="shift-toggle-btn"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6h6"/><path d="M12 18V9"/><path d="m6 12 6-6 6 6"/></svg></button>
1109
1239
  <button class="mkey" onclick="toggleAlt()" title="Toggle Alt (latches for next key)" id="alt-toggle-btn">Alt</button>
1110
1240
  <span class="mkey-sep"></span>
1111
1241
  <button class="mkey" onclick="toggleCtrlRow()" title="Toggle Ctrl keys" id="ctrl-toggle-btn">Ctrl</button>
@@ -1132,8 +1262,8 @@ svg { display: block; }
1132
1262
  <button class="mkey" onclick="selectAllTerm()" title="Select all terminal content">Select All</button>
1133
1263
  <button class="mkey" onclick="selectTermLine()" title="Select current visible line">Line</button>
1134
1264
  <span class="mkey-sep"></span>
1135
- <button class="mkey" onclick="termScrollUp()" title="Scroll up">▲</button>
1136
- <button class="mkey" onclick="termScrollDn()" title="Scroll down">▼</button>
1265
+ <button class="mkey" onclick="termScrollUp()" title="Scroll up"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"/></svg></button>
1266
+ <button class="mkey" onclick="termScrollDn()" title="Scroll down"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg></button>
1137
1267
  <button class="mkey" id="sel-scroll-btn" onclick="toggleScrollMode()" title="Toggle scroll mode">Scroll</button>
1138
1268
  <span class="mkey-sep"></span>
1139
1269
  <button class="mkey sel-done" onclick="toggleTermSelect()" title="Exit selection mode">Done</button>
@@ -1170,10 +1300,10 @@ svg { display: block; }
1170
1300
  <div class="setting-row">
1171
1301
  <label for="s-font">Font Family</label>
1172
1302
  <select id="s-font" onchange="applyFont(this.value)">
1303
+ <option value="'JetBrains Mono', 'SF Mono', 'Fira Code', Consolas, monospace">JetBrains Mono</option>
1173
1304
  <option value="'SF Mono', 'Fira Code', Consolas, monospace">System Mono</option>
1174
1305
  <option value="'Fira Code', monospace">Fira Code</option>
1175
1306
  <option value="'Cascadia Code', monospace">Cascadia Code</option>
1176
- <option value="'JetBrains Mono', monospace">JetBrains Mono</option>
1177
1307
  <option value="monospace">Monospace</option>
1178
1308
  </select>
1179
1309
  </div>
@@ -1229,10 +1359,11 @@ svg { display: block; }
1229
1359
  <div class="setting-row" style="flex-wrap:wrap;gap:6px">
1230
1360
  <label for="tunnel-url" style="flex:0 0 auto">Local URL</label>
1231
1361
  <div style="display:flex;flex:1;gap:6px;min-width:0">
1232
- <input type="text" id="tunnel-url" value="http://localhost:3000" placeholder="http://localhost:PORT" style="flex:1;min-width:0">
1233
- <button class="btn btn-primary" style="height:28px;padding:0 12px;font-size:12px;flex-shrink:0" onclick="createTunnel()">Create</button>
1362
+ <input type="text" id="tunnel-url" value="http://localhost:3000" placeholder="http://localhost:PORT" style="flex:1;min-width:0" oninput="clearFieldError('tunnel-field-error')">
1363
+ <button class="btn btn-primary" id="tunnel-create-btn" style="height:28px;padding:0 12px;font-size:12px;flex-shrink:0" onclick="createTunnel()">Create</button>
1234
1364
  </div>
1235
1365
  </div>
1366
+ <div class="field-error" id="tunnel-field-error" role="alert"></div>
1236
1367
  <div id="tunnel-list"></div>
1237
1368
  <div id="tunnel-status" style="font-size:11px;color:var(--fg2);min-height:16px;margin-top:2px"></div>
1238
1369
  </div>
@@ -1254,6 +1385,9 @@ svg { display: block; }
1254
1385
  </div>
1255
1386
  </div>
1256
1387
  </div>
1388
+ <div class="settings-section" style="margin-top:12px;border-top:1px solid var(--border);padding-top:16px">
1389
+ <button class="btn btn-ghost" id="reset-settings-btn" onclick="resetSettings()" style="width:100%">Reset settings</button>
1390
+ </div>
1257
1391
  </div>
1258
1392
  </div>
1259
1393
  </div>
@@ -1284,7 +1418,7 @@ svg { display: block; }
1284
1418
  </div>
1285
1419
 
1286
1420
  <!-- Terminal context menu -->
1287
- <div id="term-ctx-menu" role="menu" style="position:fixed;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 4px 20px var(--shadow-color);z-index:500;display:none;min-width:180px;overflow:hidden">
1421
+ <div id="term-ctx-menu" role="menu" style="position:fixed;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 4px 20px var(--shadow-color);z-index:var(--z-menu);display:none;min-width:180px;overflow:hidden">
1288
1422
  <!-- Clipboard -->
1289
1423
  <div class="ctx-item" id="term-ctx-copy" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy</div>
1290
1424
  <div class="ctx-item" id="term-ctx-paste" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="14" y="2" width="8" height="8" rx="2"/><path d="M16 14H8M8 18h8"/><path d="M5 4h4"/><rect x="2" y="6" width="16" height="16" rx="2"/></svg> Paste</div>
@@ -1300,7 +1434,7 @@ svg { display: block; }
1300
1434
  <!-- More Options -->
1301
1435
  <div class="ctx-sep"></div>
1302
1436
  <div class="ctx-submenu-wrap">
1303
- <div class="ctx-item" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg> More Options<span class="ctx-submenu-arrow">▸</span></div>
1437
+ <div class="ctx-item" role="menuitem" aria-haspopup="menu" aria-expanded="false"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg> More Options<span class="ctx-submenu-arrow"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></span></div>
1304
1438
  <div class="ctx-submenu-items">
1305
1439
  <div class="ctx-item" id="term-ctx-find" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Find in Terminal</div>
1306
1440
  <div class="ctx-item" id="term-ctx-zoom-in" role="menuitem"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg> Zoom In</div>
@@ -1318,7 +1452,7 @@ svg { display: block; }
1318
1452
  </div>
1319
1453
 
1320
1454
  <!-- Fuzzy Finder Modal -->
1321
- <div class="overlay" id="finder-overlay">
1455
+ <div class="overlay" id="finder-overlay" role="dialog" aria-modal="true" aria-label="Search files">
1322
1456
  <div class="modal modal-md" style="padding:12px">
1323
1457
  <input id="finder-input" type="text" placeholder="Search files and folders…" autocomplete="off" oninput="doFinderSearch()" onkeydown="finderKeydown(event)">
1324
1458
  <div id="finder-results"></div>
@@ -1326,6 +1460,31 @@ svg { display: block; }
1326
1460
  </div>
1327
1461
  </div>
1328
1462
 
1463
+ <!-- Keyboard Shortcuts Modal -->
1464
+ <div class="overlay" id="shortcuts-overlay">
1465
+ <div class="modal modal-md">
1466
+ <h2>Keyboard Shortcuts</h2>
1467
+ <div class="shortcuts-table-wrap">
1468
+ <table class="shortcuts-table">
1469
+ <tbody>
1470
+ <tr><td><kbd>Ctrl</kbd>+<kbd>P</kbd></td><td>File finder</td></tr>
1471
+ <tr><td><kbd>Ctrl</kbd>+<kbd>T</kbd></td><td>New terminal tab</td></tr>
1472
+ <tr><td><kbd>Ctrl</kbd>+<kbd>W</kbd></td><td>Close tab</td></tr>
1473
+ <tr><td><kbd>Ctrl</kbd>+<kbd>B</kbd></td><td>Toggle file explorer</td></tr>
1474
+ <tr><td><kbd>Ctrl</kbd>+<kbd>F</kbd></td><td>Search in terminal</td></tr>
1475
+ <tr><td><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>→</kbd>/<kbd>←</kbd></td><td>Cycle tabs</td></tr>
1476
+ <tr><td><kbd>Ctrl</kbd>+<kbd>S</kbd></td><td>Save editor file</td></tr>
1477
+ <tr><td><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></td><td>Refresh preview</td></tr>
1478
+ <tr><td><kbd>Esc</kbd></td><td>Close menus / dialogs</td></tr>
1479
+ </tbody>
1480
+ </table>
1481
+ </div>
1482
+ <div class="modal-actions">
1483
+ <button class="btn btn-primary" onclick="closeOverlay('shortcuts-overlay')">Close</button>
1484
+ </div>
1485
+ </div>
1486
+ </div>
1487
+
1329
1488
  <!-- System Stats Modal -->
1330
1489
  <div class="overlay" id="sys-overlay">
1331
1490
  <div class="modal">
@@ -1382,10 +1541,11 @@ svg { display: block; }
1382
1541
  <div class="modal-row">
1383
1542
  <label for="rename-input">New Name</label>
1384
1543
  <input type="text" id="rename-input" onkeydown="if(event.key==='Enter')confirmRename()">
1544
+ <div class="field-error" id="rename-error" role="alert"></div>
1385
1545
  </div>
1386
1546
  <div class="modal-actions">
1387
1547
  <button class="btn btn-ghost" onclick="closeOverlay('rename-overlay')">Cancel</button>
1388
- <button class="btn btn-primary" onclick="confirmRename()">Rename</button>
1548
+ <button class="btn btn-primary" id="rename-ok-btn" onclick="confirmRename()">Rename</button>
1389
1549
  </div>
1390
1550
  </div>
1391
1551
  </div>
@@ -1397,10 +1557,11 @@ svg { display: block; }
1397
1557
  <div class="modal-row">
1398
1558
  <label for="newfolder-input">Folder Name</label>
1399
1559
  <input type="text" id="newfolder-input" onkeydown="if(event.key==='Enter')confirmNewFolder()">
1560
+ <div class="field-error" id="newfolder-error" role="alert"></div>
1400
1561
  </div>
1401
1562
  <div class="modal-actions">
1402
1563
  <button class="btn btn-ghost" onclick="closeOverlay('newfolder-overlay')">Cancel</button>
1403
- <button class="btn btn-primary" onclick="confirmNewFolder()">Create</button>
1564
+ <button class="btn btn-primary" id="newfolder-ok-btn" onclick="confirmNewFolder()">Create</button>
1404
1565
  </div>
1405
1566
  </div>
1406
1567
  </div>
@@ -1412,10 +1573,11 @@ svg { display: block; }
1412
1573
  <div class="modal-row">
1413
1574
  <label for="newfile-input">File Name</label>
1414
1575
  <input type="text" id="newfile-input" onkeydown="if(event.key==='Enter')confirmNewFile()">
1576
+ <div class="field-error" id="newfile-error" role="alert"></div>
1415
1577
  </div>
1416
1578
  <div class="modal-actions">
1417
1579
  <button class="btn btn-ghost" onclick="closeOverlay('newfile-overlay')">Cancel</button>
1418
- <button class="btn btn-primary" onclick="confirmNewFile()">Create</button>
1580
+ <button class="btn btn-primary" id="newfile-ok-btn" onclick="confirmNewFile()">Create</button>
1419
1581
  </div>
1420
1582
  </div>
1421
1583
  </div>
@@ -1436,13 +1598,25 @@ svg { display: block; }
1436
1598
  </div>
1437
1599
  </div>
1438
1600
 
1601
+ <!-- Confirm Dialog -->
1602
+ <div class="overlay" id="confirm-overlay">
1603
+ <div class="modal modal-sm">
1604
+ <h2 id="confirm-title">Confirm</h2>
1605
+ <p id="confirm-msg" style="font-size:13px;color:var(--fg2);line-height:1.6;white-space:pre-wrap;word-break:break-word"></p>
1606
+ <div class="modal-actions">
1607
+ <button class="btn btn-ghost" id="confirm-cancel-btn">Cancel</button>
1608
+ <button class="btn btn-primary" id="confirm-ok-btn">OK</button>
1609
+ </div>
1610
+ </div>
1611
+ </div>
1612
+
1439
1613
  <!-- Image Viewer -->
1440
- <div class="overlay" id="image-viewer">
1614
+ <div class="overlay" id="image-viewer" role="dialog" aria-modal="true" aria-label="Image preview">
1441
1615
  <div class="modal">
1442
1616
  <div class="iv-header">
1443
1617
  <span class="iv-name" id="iv-name"></span>
1444
1618
  <button class="iv-close" onclick="closeImageViewer()" aria-label="Close image viewer">
1445
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1619
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
1446
1620
  </button>
1447
1621
  </div>
1448
1622
  <div id="iv-loading" class="iv-loading">Loading…</div>
@@ -1457,7 +1631,7 @@ svg { display: block; }
1457
1631
  <input type="file" id="upload-input" multiple style="display:none" onchange="handleUploadInput()">
1458
1632
 
1459
1633
  <!-- PWA Install Banner -->
1460
- <div id="install-banner" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:500;background:var(--bg3);border-top:1px solid var(--border);padding:12px 16px;padding-bottom:calc(12px + env(safe-area-inset-bottom));align-items:center;gap:10px;box-shadow:0 -4px 16px rgba(0,0,0,0.3)">
1634
+ <div id="install-banner" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:var(--z-menu);background:var(--bg3);border-top:1px solid var(--border);padding:12px 16px;padding-bottom:calc(12px + env(safe-area-inset-bottom));align-items:center;gap:10px;box-shadow:0 -4px 16px rgba(0,0,0,0.3)">
1461
1635
  <span class="install-msg" style="flex:1;font-size:13px">Install WebTun for the best experience</span>
1462
1636
  <button class="btn btn-ghost" style="height:32px;padding:0 12px;font-size:12px" onclick="dismissInstall()">Not now</button>
1463
1637
  <button class="btn btn-primary" style="height:32px;padding:0 12px;font-size:12px" onclick="installPWA()">Install</button>
@@ -1482,8 +1656,8 @@ let renamePath = '';
1482
1656
  let editorPath = '';
1483
1657
  let editorOriginalContent = '';
1484
1658
  let hasTmux = false;
1485
- let settings = {
1486
- theme: 'light', fontSize: 14, font: "'SF Mono', 'Fira Code', Consolas, monospace",
1659
+ let settings = {
1660
+ theme: 'light', fontSize: 14, font: "'JetBrains Mono', 'SF Mono', 'Fira Code', Consolas, monospace",
1487
1661
  cursor: 'block', blink: true, scrollback: 5000, bell: false,
1488
1662
  mobilekeys: false, confirmclose: true, datasaver: false, autostart: false, keepAwake: false, termRightClick: true
1489
1663
  };
@@ -1619,14 +1793,33 @@ async function unlockApp() {
1619
1793
  restoreTunnels();
1620
1794
  setupSwipeGestures();
1621
1795
  setupFileListTouch();
1796
+ setupFileListKeyboard();
1797
+ setupCtxMenuKeyboard();
1798
+ setupMoreMenuKeyboard();
1622
1799
  restoreSidebarWidth();
1623
1800
  setupSidebarResize();
1624
1801
  setupEditorResize();
1625
1802
  window.addEventListener('resize', updateSidebarNarrowClass);
1626
1803
  window._cleanups.push(() => window.removeEventListener('resize', updateSidebarNarrowClass));
1627
- document.getElementById('tab-bar').setAttribute('role', 'tablist');
1804
+ document.getElementById('tab-scroll').setAttribute('role', 'tablist');
1628
1805
  document.getElementById('file-list-wrap').setAttribute('role', 'listbox');
1629
1806
 
1807
+ // Arrow-key navigation between tabs when focus is in the tab strip
1808
+ document.getElementById('tab-scroll').addEventListener('keydown', e => {
1809
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
1810
+ e.preventDefault();
1811
+ cycleTab(e.key === 'ArrowRight' ? 1 : -1);
1812
+ const t = getActiveTab();
1813
+ if (t?.el) t.el.focus();
1814
+ } else if (e.key === 'Home') {
1815
+ e.preventDefault();
1816
+ if (tabs.length) { activateTab(tabs[0].id); tabs[0].el.focus(); }
1817
+ } else if (e.key === 'End') {
1818
+ e.preventDefault();
1819
+ if (tabs.length) { activateTab(tabs[tabs.length - 1].id); tabs[tabs.length - 1].el.focus(); }
1820
+ }
1821
+ });
1822
+
1630
1823
  // Listen for system theme changes
1631
1824
  const systemThemeMql = window.matchMedia('(prefers-color-scheme: dark)');
1632
1825
  const onSystemThemeChange = () => { if (settings.theme === 'system') applyTheme('system'); };
@@ -1726,7 +1919,7 @@ function setupTabDragDrop(tabEl, id) {
1726
1919
  const [moved] = tabs.splice(fromIdx, 1);
1727
1920
  const targetIdx = tabs.findIndex(t => t.id === id);
1728
1921
  tabs.splice(targetIdx, 0, moved);
1729
- const bar = document.getElementById('tab-bar');
1922
+ const bar = document.getElementById('tab-scroll');
1730
1923
  if (moved.el && moved.el.parentNode === bar) bar.insertBefore(moved.el, tabEl);
1731
1924
  saveTabState();
1732
1925
  };
@@ -1852,7 +2045,7 @@ function createTabButton(tab) {
1852
2045
  setupTabInlineRename(titleSpan, tab);
1853
2046
  setupTabSwipeGesture(tabEl, id);
1854
2047
 
1855
- document.getElementById('tab-bar').insertBefore(tabEl, document.getElementById('new-tab-btn'));
2048
+ document.getElementById('tab-scroll').appendChild(tabEl);
1856
2049
  tab.el = tabEl;
1857
2050
  }
1858
2051
 
@@ -1860,13 +2053,22 @@ function createTerminalWrapper(tab) {
1860
2053
  const wrapper = document.createElement('div');
1861
2054
  wrapper.className = 'term-wrapper';
1862
2055
  wrapper.dataset.id = tab.id;
2056
+ const loading = document.createElement('div');
2057
+ loading.className = 'term-loading';
2058
+ loading.innerHTML = '<span class="tl-spinner"></span><span>Connecting…</span>';
2059
+ wrapper.appendChild(loading);
2060
+ tab.loadingEl = loading;
1863
2061
  document.getElementById('terminals').appendChild(wrapper);
1864
2062
  tab.wrapper = wrapper;
1865
2063
  }
1866
2064
 
2065
+ function hideTermLoading(tab) {
2066
+ if (tab && tab.loadingEl) tab.loadingEl.classList.add('hidden');
2067
+ }
2068
+
1867
2069
  function newTab(title, sessionId, dir) {
1868
2070
  const id = ++tabCounter;
1869
- const sid = sessionId || (hasTmux ? uuid() : null);
2071
+ const sid = sessionId || uuid();
1870
2072
  const tab = { id, sessionId: sid, title: title || `Term ${nextTermNumber()}`, term: null, fitAddon: null, searchAddon: null, ws: null, el: null, wrapper: null, closed: false, reconnectDelay: 1000, dataDisposable: null, resizeDisposable: null, resizeObserver: null, cwd: dir || currentPath };
1871
2073
  tabs.push(tab);
1872
2074
  createTabButton(tab);
@@ -1978,10 +2180,11 @@ function connectWebSocket(tab, isReconnect = false) {
1978
2180
  ws.onopen = () => {
1979
2181
  tab.reconnectDelay = 1000;
1980
2182
  tab.reconnectAttempts = 0;
2183
+ hideTermLoading(tab);
1981
2184
  updateConnStatus(true);
1982
2185
  const banner = document.getElementById('reconnect-banner');
1983
2186
  if (banner) {
1984
- banner.innerHTML = '⚠️ Reconnecting…';
2187
+ banner.innerHTML = '<span class="reconnect-spinner"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></span> Reconnecting…';
1985
2188
  banner.style.display = 'none';
1986
2189
  }
1987
2190
  if (isReconnect) {
@@ -2078,7 +2281,7 @@ function connectWebSocket(tab, isReconnect = false) {
2078
2281
  if (tab.reconnectAttempts >= 3) {
2079
2282
  const banner = document.getElementById('reconnect-banner');
2080
2283
  if (banner) {
2081
- banner.innerHTML = '⚠️ Connection lost. <button class="btn btn-primary" data-action="manual-reconnect" style="height:26px;padding:0 12px;font-size:11px;margin-left:8px">Reconnect</button>';
2284
+ banner.innerHTML = 'Connection lost. <button class="btn btn-primary" data-action="manual-reconnect" style="height:26px;padding:0 12px;font-size:11px;margin-left:8px">Reconnect</button>';
2082
2285
  banner.style.display = 'block';
2083
2286
  }
2084
2287
  } else {
@@ -2102,7 +2305,7 @@ function connectWebSocket(tab, isReconnect = false) {
2102
2305
  function manualReconnect() {
2103
2306
  const banner = document.getElementById('reconnect-banner');
2104
2307
  if (banner) {
2105
- banner.innerHTML = '⚠️ Reconnecting…';
2308
+ banner.innerHTML = '<span class="reconnect-spinner"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg></span> Reconnecting…';
2106
2309
  banner.style.display = 'block';
2107
2310
  }
2108
2311
  tabs.forEach(tab => {
@@ -2115,15 +2318,19 @@ function manualReconnect() {
2115
2318
  });
2116
2319
  }
2117
2320
 
2118
- function closeTab(e, id) {
2321
+ async function closeTab(e, id) {
2119
2322
  e.stopPropagation();
2120
- if (settings.confirmclose) { if (!confirm(tabs.length === 1 ? 'Close last terminal?' : 'Close terminal?')) return; }
2323
+ if (settings.confirmclose) {
2324
+ const ok = await confirmDialog({ title: 'Close terminal', message: tabs.length === 1 ? 'Close last terminal?' : 'Close terminal?', okText: 'Close', cancelText: 'Cancel' });
2325
+ if (!ok) return;
2326
+ }
2121
2327
 
2122
2328
  // Check for unsaved editor changes
2123
2329
  const editorOpen = document.getElementById('editor-view').classList.contains('open');
2124
2330
  const currentContent = editor ? editor.getValue() : '';
2125
2331
  if (editorOpen && currentContent !== editorOriginalContent) {
2126
- if (!confirm('You have unsaved changes. Close anyway?')) return;
2332
+ const ok = await confirmDialog({ title: 'Unsaved changes', message: 'You have unsaved changes. Close anyway?', okText: 'Close', cancelText: 'Cancel', danger: true });
2333
+ if (!ok) return;
2127
2334
  }
2128
2335
 
2129
2336
  const tab = tabs.find(t => t.id === id);
@@ -2141,7 +2348,7 @@ function closeTab(e, id) {
2141
2348
  tab.term?.dispose();
2142
2349
  tab.el?.remove();
2143
2350
  tab.wrapper?.remove();
2144
- // Kill the backing tmux session so it doesn't pile up
2351
+ // Kill the backing session (tmux or in-memory) so it doesn't pile up
2145
2352
  if (tab.sessionId) api(`/api/sessions/${tab.sessionId}`, { method: 'DELETE' });
2146
2353
  tabs = tabs.filter(t => t.id !== id);
2147
2354
  saveTabState();
@@ -2164,11 +2371,7 @@ function initTerminal(tab) {
2164
2371
  convertEol: false,
2165
2372
  bellStyle: settings.bell ? 'sound' : 'none',
2166
2373
  smoothScrollDuration: 80,
2167
- selectionTheme: {
2168
- extension: true,
2169
- foreground: '#1a1b26',
2170
- background: '#7aa2f744'
2171
- }
2374
+ selectionTheme: getXtermSelectionTheme()
2172
2375
  };
2173
2376
 
2174
2377
  const term = new Terminal(cfg);
@@ -2293,6 +2496,7 @@ function initTerminal(tab) {
2293
2496
  top = Math.max(0, Math.min(top, vh - mh));
2294
2497
  menu.style.left = left + 'px';
2295
2498
  menu.style.top = top + 'px';
2499
+ menu.querySelector('.ctx-item')?.focus();
2296
2500
  });
2297
2501
 
2298
2502
  // Touch-to-mouse translation for TUI apps (mobile)
@@ -2523,7 +2727,7 @@ function layoutTiles() {
2523
2727
  const tthClose = document.createElement('button');
2524
2728
  tthClose.className = 'tth-close';
2525
2729
  tthClose.setAttribute('aria-label', 'Close tab');
2526
- tthClose.innerHTML = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
2730
+ tthClose.innerHTML = '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
2527
2731
  hdr.appendChild(tthClose);
2528
2732
  hdr.addEventListener('click', e => {
2529
2733
  if (e.target.closest('.tth-close')) return;
@@ -2557,10 +2761,32 @@ function getXtermTheme() {
2557
2761
  };
2558
2762
  }
2559
2763
 
2764
+ function getXtermSelectionTheme() {
2765
+ const s = getComputedStyle(document.documentElement);
2766
+ const g = v => s.getPropertyValue(v).trim();
2767
+ return {
2768
+ extension: true,
2769
+ foreground: g('--bg'),
2770
+ background: g('--accent') + '44'
2771
+ };
2772
+ }
2773
+
2560
2774
  // ═══════════════════════════════════════════════════════
2561
2775
  // FILE EXPLORER
2562
2776
  // ═══════════════════════════════════════════════════════
2563
2777
  let loadFilesAbortController = null;
2778
+ let fileListLoadingTimer = null;
2779
+ function setFileListLoading(on) {
2780
+ const wrap = document.getElementById('file-list-wrap');
2781
+ if (!wrap) return;
2782
+ clearTimeout(fileListLoadingTimer);
2783
+ if (on) {
2784
+ fileListLoadingTimer = setTimeout(() => wrap.classList.add('loading'), 150);
2785
+ } else {
2786
+ fileListLoadingTimer = null;
2787
+ wrap.classList.remove('loading');
2788
+ }
2789
+ }
2564
2790
 
2565
2791
  async function loadFiles(dir) {
2566
2792
  if (loadFilesAbortController) loadFilesAbortController.abort();
@@ -2582,11 +2808,13 @@ async function loadFiles(dir) {
2582
2808
 
2583
2809
  const list = document.getElementById('file-list');
2584
2810
 
2811
+ setFileListLoading(true);
2585
2812
  let data;
2586
2813
  try {
2587
2814
  data = await api(`/api/files?path=${encodeURIComponent(dir)}`, { signal: loadFilesAbortController.signal });
2588
2815
  } catch (e) {
2589
- if (e.name === 'AbortError') return;
2816
+ if (e.name === 'AbortError') { setFileListLoading(false); return; }
2817
+ setFileListLoading(false);
2590
2818
  const errorDiv = document.createElement('div');
2591
2819
  errorDiv.style.cssText = 'padding:16px;text-align:center;';
2592
2820
  errorDiv.innerHTML = '<div style="color:var(--red);margin-bottom:8px">Failed to load directory</div>' +
@@ -2598,6 +2826,7 @@ async function loadFiles(dir) {
2598
2826
  }
2599
2827
 
2600
2828
  if (!data.files) {
2829
+ setFileListLoading(false);
2601
2830
  if (dir !== homeDir) loadFiles(homeDir);
2602
2831
  return;
2603
2832
  }
@@ -2682,16 +2911,25 @@ async function loadFiles(dir) {
2682
2911
  if (list.children.length === 0) {
2683
2912
  const empty = document.createElement('div');
2684
2913
  empty.className = 'file-list-empty';
2685
- empty.innerHTML = '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg><span>Empty directory</span>';
2914
+ empty.innerHTML = '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg><span>Empty directory</span>' +
2915
+ '<div class="empty-actions">' +
2916
+ '<button class="btn btn-ghost" onclick="openOverlay(\'newfile-overlay\');document.getElementById(\'newfile-input\').focus()">New file</button>' +
2917
+ '<button class="btn btn-ghost" onclick="openOverlay(\'newfolder-overlay\');document.getElementById(\'newfolder-input\').focus()">New folder</button>' +
2918
+ '<button class="btn btn-ghost" onclick="uploadFiles()">Upload</button>' +
2919
+ '</div>';
2686
2920
  list.appendChild(empty);
2687
2921
  }
2688
2922
 
2689
2923
  updateBackBtn();
2924
+ setFileListLoading(false);
2690
2925
  }
2691
2926
 
2927
+ let fileItemCounter = 0;
2928
+
2692
2929
  function makeFileItem(name, isDir, fullPath, size, icon) {
2693
2930
  const div = document.createElement('div');
2694
2931
  div.className = 'file-item';
2932
+ div.id = 'file-item-' + (++fileItemCounter);
2695
2933
  div.setAttribute('role', 'option');
2696
2934
  div.setAttribute('aria-selected', 'false');
2697
2935
  div.dataset.path = fullPath;
@@ -2701,6 +2939,7 @@ function makeFileItem(name, isDir, fullPath, size, icon) {
2701
2939
  <span class="file-icon">${icon}</span>
2702
2940
  <span class="file-name ${isDir ? 'file-dir' : ''}" title="${escHtml(fullPath)}">${escHtml(name)}</span>
2703
2941
  ${size ? `<span class="file-size">${escHtml(size)}</span>` : ''}
2942
+ <span class="file-ellipsis" role="button" tabindex="0" aria-label="More actions for ${escHtml(name)}" title="More actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg></span>
2704
2943
  `;
2705
2944
  div.addEventListener('click', e => {
2706
2945
  if (e.target.closest('.file-select-check')) {
@@ -2708,6 +2947,13 @@ function makeFileItem(name, isDir, fullPath, size, icon) {
2708
2947
  if (selectedFiles.length > 0) document.getElementById('select-actions').style.display = 'flex';
2709
2948
  return;
2710
2949
  }
2950
+ if (e.target.closest('.file-ellipsis')) {
2951
+ e.stopPropagation();
2952
+ const rect = div.querySelector('.file-ellipsis').getBoundingClientRect();
2953
+ const m = fullPath.match(/\.([^.]+)$/);
2954
+ showCtxMenu({ clientX: rect.left + rect.width / 2, clientY: rect.bottom + 4, preventDefault() {}, stopPropagation() {} }, { path: fullPath, name, isDir, ext: m ? '.' + m[1].toLowerCase() : '' });
2955
+ return;
2956
+ }
2711
2957
  if (selectMode) {
2712
2958
  toggleFileSelection(fullPath, div);
2713
2959
  return;
@@ -2716,9 +2962,58 @@ function makeFileItem(name, isDir, fullPath, size, icon) {
2716
2962
  else if (isImageFile(fullPath)) openImageViewer(fullPath);
2717
2963
  else openFileEditor(fullPath);
2718
2964
  });
2965
+ const ell = div.querySelector('.file-ellipsis');
2966
+ if (ell) {
2967
+ ell.addEventListener('keydown', e => {
2968
+ if (e.key === 'Enter' || e.key === ' ') {
2969
+ e.preventDefault();
2970
+ e.stopPropagation();
2971
+ const rect = ell.getBoundingClientRect();
2972
+ const m = fullPath.match(/\.([^.]+)$/);
2973
+ showCtxMenu({ clientX: rect.left + rect.width / 2, clientY: rect.bottom + 4, preventDefault() {}, stopPropagation() {} }, { path: fullPath, name, isDir, ext: m ? '.' + m[1].toLowerCase() : '' });
2974
+ }
2975
+ });
2976
+ }
2719
2977
  return div;
2720
2978
  }
2721
2979
 
2980
+ function setupFileListKeyboard() {
2981
+ const list = document.getElementById('file-list');
2982
+ if (!list || list.dataset._kbSetup) return;
2983
+ list.dataset._kbSetup = '1';
2984
+ list.setAttribute('tabindex', '0');
2985
+ list.addEventListener('keydown', e => {
2986
+ const items = [...list.querySelectorAll('.file-item')];
2987
+ if (!items.length) return;
2988
+ let idx = items.findIndex(el => el.classList.contains('focused'));
2989
+ const focusIdx = i => {
2990
+ if (i < 0 || i >= items.length) return;
2991
+ items.forEach(el => el.classList.remove('focused'));
2992
+ items[i].classList.add('focused');
2993
+ if (items[i].id) list.setAttribute('aria-activedescendant', items[i].id);
2994
+ items[i].scrollIntoView({ block: 'nearest' });
2995
+ };
2996
+ if (e.key === 'ArrowDown') { e.preventDefault(); focusIdx(idx < 0 ? 0 : Math.min(idx + 1, items.length - 1)); }
2997
+ else if (e.key === 'ArrowUp') { e.preventDefault(); focusIdx(idx < 0 ? items.length - 1 : Math.max(0, idx - 1)); }
2998
+ else if (e.key === 'Home') { e.preventDefault(); focusIdx(0); }
2999
+ else if (e.key === 'End') { e.preventDefault(); focusIdx(items.length - 1); }
3000
+ else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (idx >= 0) items[idx].click(); }
3001
+ else if (e.key === 'Backspace' || e.key === 'ArrowLeft') { e.preventDefault(); navigateUp(); }
3002
+ else if ((e.key === 'a' || e.key === 'A') && (e.ctrlKey || e.metaKey)) { e.preventDefault(); if (selectMode) selectAllFiles(); }
3003
+ else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
3004
+ const prefix = (list._typeAhead || '') + e.key.toLowerCase();
3005
+ list._typeAhead = prefix;
3006
+ clearTimeout(list._typeAheadTimer);
3007
+ list._typeAheadTimer = setTimeout(() => { list._typeAhead = ''; }, 500);
3008
+ const found = items.findIndex(el => {
3009
+ const nm = el.querySelector('.file-name');
3010
+ return nm && nm.textContent.toLowerCase().startsWith(prefix);
3011
+ });
3012
+ if (found >= 0) focusIdx(found);
3013
+ }
3014
+ });
3015
+ }
3016
+
2722
3017
  function refreshFiles() {
2723
3018
  if (currentPath) loadFiles(currentPath);
2724
3019
  }
@@ -2992,8 +3287,77 @@ document.querySelector('#term-ctx-menu .ctx-submenu-wrap > .ctx-item').addEventL
2992
3287
  e.stopPropagation();
2993
3288
  const wrap = e.currentTarget.closest('.ctx-submenu-wrap');
2994
3289
  wrap.classList.toggle('open');
3290
+ e.currentTarget.setAttribute('aria-expanded', wrap.classList.contains('open'));
2995
3291
  });
2996
3292
 
3293
+ // ═══════════════════════════════════════════════════════
3294
+ // CONTEXT MENU KEYBOARD NAVIGATION
3295
+ // ═══════════════════════════════════════════════════════
3296
+ function setupCtxMenuKeyboard() {
3297
+ ['ctx-menu', 'term-ctx-menu'].forEach(id => {
3298
+ const menu = document.getElementById(id);
3299
+ if (!menu) return;
3300
+ menu.querySelectorAll('.ctx-item').forEach(el => {
3301
+ if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '-1');
3302
+ });
3303
+ const openSubmenu = wrap => {
3304
+ wrap.classList.add('open');
3305
+ const toggle = wrap.querySelector(':scope > .ctx-item');
3306
+ if (toggle) toggle.setAttribute('aria-expanded', 'true');
3307
+ const first = wrap.querySelector('.ctx-submenu-items .ctx-item');
3308
+ if (first) first.focus();
3309
+ };
3310
+ const closeSubmenu = wrap => {
3311
+ wrap.classList.remove('open');
3312
+ const toggle = wrap.querySelector(':scope > .ctx-item');
3313
+ if (toggle) toggle.setAttribute('aria-expanded', 'false');
3314
+ if (toggle) toggle.focus();
3315
+ };
3316
+ const visibleItems = () => [...menu.querySelectorAll('.ctx-item')].filter(el => {
3317
+ const subWrap = el.closest('.ctx-submenu-items');
3318
+ if (subWrap && !subWrap.parentElement.classList.contains('open')) return false;
3319
+ return el.offsetParent !== null;
3320
+ });
3321
+ menu.addEventListener('keydown', e => {
3322
+ const items = visibleItems();
3323
+ if (!items.length) return;
3324
+ let idx = items.indexOf(document.activeElement);
3325
+ const activeItem = document.activeElement;
3326
+ switch (e.key) {
3327
+ case 'Escape': {
3328
+ const wrap = activeItem && activeItem.closest('.ctx-submenu-wrap');
3329
+ if (wrap && wrap.classList.contains('open')) { closeSubmenu(wrap); e.preventDefault(); return; }
3330
+ if (id === 'ctx-menu') document.getElementById('ctx-menu').classList.remove('open');
3331
+ else hideTermCtxMenu();
3332
+ e.preventDefault();
3333
+ return;
3334
+ }
3335
+ case 'ArrowDown': idx = (idx + 1) % items.length; break;
3336
+ case 'ArrowUp': idx = (idx - 1 + items.length) % items.length; break;
3337
+ case 'Home': idx = 0; break;
3338
+ case 'End': idx = items.length - 1; break;
3339
+ case 'ArrowRight': {
3340
+ const wrap = activeItem && activeItem.closest('.ctx-submenu-wrap');
3341
+ if (wrap && !wrap.classList.contains('open')) { e.preventDefault(); openSubmenu(wrap); }
3342
+ return;
3343
+ }
3344
+ case 'ArrowLeft': {
3345
+ const itemsWrap = activeItem && activeItem.closest('.ctx-submenu-items');
3346
+ if (itemsWrap && itemsWrap.parentElement) { e.preventDefault(); closeSubmenu(itemsWrap.parentElement); }
3347
+ return;
3348
+ }
3349
+ case 'Enter':
3350
+ case ' ':
3351
+ if (activeItem && activeItem.classList.contains('ctx-item')) { e.preventDefault(); activeItem.click(); }
3352
+ return;
3353
+ default: return;
3354
+ }
3355
+ e.preventDefault();
3356
+ if (idx >= 0 && items[idx]) items[idx].focus();
3357
+ });
3358
+ });
3359
+ }
3360
+
2997
3361
  function showCtxMenu(e, file) {
2998
3362
  e.preventDefault();
2999
3363
  ctxTarget = file;
@@ -3021,6 +3385,8 @@ function showCtxMenu(e, file) {
3021
3385
  menu.style.top = top + 'px';
3022
3386
  menu.classList.add('open');
3023
3387
  menu.style.display = '';
3388
+ const firstItem = menu.querySelector('.ctx-item');
3389
+ if (firstItem) firstItem.focus();
3024
3390
  }
3025
3391
 
3026
3392
  document.addEventListener('click', () => {
@@ -3183,19 +3549,23 @@ function startRename(path, name) {
3183
3549
 
3184
3550
  async function confirmRename() {
3185
3551
  const newName = document.getElementById('rename-input').value.trim();
3186
- if (!newName) return;
3552
+ if (!newName) { showFieldError('rename-error', 'Enter a new name'); return; }
3553
+ clearFieldError('rename-error');
3554
+ setBtnBusy(document.getElementById('rename-ok-btn'), true);
3187
3555
  const r = await api('/api/files/rename', {
3188
3556
  method: 'POST',
3189
3557
  headers: { 'Content-Type': 'application/json' },
3190
3558
  body: JSON.stringify({ oldPath: renamePath, newName })
3191
3559
  });
3560
+ setBtnBusy(document.getElementById('rename-ok-btn'), false);
3192
3561
  if (r.success) { toast('Renamed', 'success'); refreshFiles(); }
3193
- else toast(r.error, 'error');
3562
+ else showFieldError('rename-error', r.error || 'Rename failed');
3194
3563
  closeOverlay('rename-overlay');
3195
3564
  }
3196
3565
 
3197
3566
  async function deleteFile(file) {
3198
- if (!confirm(`Delete "${file.name}"?${file.isDir ? '\n\nThis will delete the entire directory.' : ''}`)) return;
3567
+ const ok = await confirmDialog({ title: 'Delete', message: `Delete "${file.name}"?${file.isDir ? '\n\nThis will delete the entire directory.' : ''}`, okText: 'Delete', danger: true });
3568
+ if (!ok) return;
3199
3569
  const r = await api(`/api/files?path=${encodeURIComponent(file.path)}`, { method: 'DELETE' });
3200
3570
  if (r.success) { toast('Deleted', 'success'); refreshFiles(); }
3201
3571
  else toast(r.error, 'error');
@@ -3239,6 +3609,8 @@ function toggleSelectMode() {
3239
3609
  selectedFiles = [];
3240
3610
  document.getElementById('select-actions').style.display = selectMode ? 'flex' : 'none';
3241
3611
  document.getElementById('sidebar').dataset.selectMode = selectMode ? 'true' : '';
3612
+ const flw = document.getElementById('file-list-wrap');
3613
+ if (flw) flw.setAttribute('aria-multiselectable', String(selectMode));
3242
3614
  document.getElementById('select-count').textContent = '0 selected';
3243
3615
  document.querySelector('[onclick="toggleSelectMode()"]')?.classList.toggle('active', selectMode);
3244
3616
  refreshFiles();
@@ -3302,7 +3674,8 @@ function clearSelection() {
3302
3674
  async function deleteSelected() {
3303
3675
  if (!selectedFiles.length) return;
3304
3676
  const msg = `Delete ${selectedFiles.length} item${selectedFiles.length > 1 ? 's' : ''}?`;
3305
- if (!confirm(msg)) return;
3677
+ const ok = await confirmDialog({ title: 'Delete', message: msg, okText: 'Delete', danger: true });
3678
+ if (!ok) return;
3306
3679
  const results = await Promise.allSettled(
3307
3680
  [...selectedFiles].map(p =>
3308
3681
  api(`/api/files?path=${encodeURIComponent(p)}`, { method: 'DELETE' })
@@ -3343,15 +3716,17 @@ function newFolder() {
3343
3716
 
3344
3717
  async function confirmNewFolder() {
3345
3718
  const name = document.getElementById('newfolder-input').value.trim();
3346
- if (!name) return;
3719
+ if (!name) { showFieldError('newfolder-error', 'Enter a folder name'); return; }
3720
+ clearFieldError('newfolder-error');
3721
+ setBtnBusy(document.getElementById('newfolder-ok-btn'), true);
3347
3722
  const r = await api('/api/files/mkdir', {
3348
3723
  method: 'POST',
3349
3724
  headers: { 'Content-Type': 'application/json' },
3350
3725
  body: JSON.stringify({ path: joinPath(currentPath, name) })
3351
3726
  });
3352
- if (r.success) { toast('Folder created', 'success'); refreshFiles(); }
3353
- else toast(r.error, 'error');
3354
- closeOverlay('newfolder-overlay');
3727
+ setBtnBusy(document.getElementById('newfolder-ok-btn'), false);
3728
+ if (r.success) { toast('Folder created', 'success'); refreshFiles(); closeOverlay('newfolder-overlay'); }
3729
+ else showFieldError('newfolder-error', r.error || 'Could not create folder');
3355
3730
  }
3356
3731
 
3357
3732
  function newFile() {
@@ -3362,15 +3737,18 @@ function newFile() {
3362
3737
 
3363
3738
  async function confirmNewFile() {
3364
3739
  const name = document.getElementById('newfile-input').value.trim();
3365
- if (!name) return;
3740
+ if (!name) { showFieldError('newfile-error', 'Enter a file name'); return; }
3741
+ clearFieldError('newfile-error');
3742
+ setBtnBusy(document.getElementById('newfile-ok-btn'), true);
3366
3743
  const filePath = joinPath(currentPath, name);
3367
3744
  const r = await api('/api/files/touch', {
3368
3745
  method: 'POST',
3369
3746
  headers: { 'Content-Type': 'application/json' },
3370
3747
  body: JSON.stringify({ path: filePath })
3371
3748
  });
3749
+ setBtnBusy(document.getElementById('newfile-ok-btn'), false);
3372
3750
  if (r.success) { refreshFiles(); closeOverlay('newfile-overlay'); openFileEditor(filePath); }
3373
- else toast(r.error, 'error');
3751
+ else showFieldError('newfile-error', r.error || 'Could not create file');
3374
3752
  }
3375
3753
 
3376
3754
  // Upload
@@ -3473,11 +3851,11 @@ async function uploadFileList(items) {
3473
3851
 
3474
3852
  // Final result
3475
3853
  if (failedItems.length === 0) {
3476
- setUploadProgress(100, 'success', `✓ ${completed}/${total} uploaded`);
3854
+ setUploadProgress(100, 'success', `${completed}/${total} uploaded`);
3477
3855
  } else if (completed > 0) {
3478
- setUploadProgress(100, 'error', `⚠ ${completed} ok, ${failedItems.length} failed`);
3856
+ setUploadProgress(100, 'error', `${completed} ok, ${failedItems.length} failed`);
3479
3857
  } else {
3480
- setUploadProgress(100, 'error', `✗ All ${total} failed`);
3858
+ setUploadProgress(100, 'error', `All ${total} failed`);
3481
3859
  }
3482
3860
 
3483
3861
  clearUploadProgress(2000);
@@ -3512,9 +3890,17 @@ function initCodeMirror() {
3512
3890
  },
3513
3891
  }
3514
3892
  });
3893
+ editor.on('change', () => updateEditorDirty());
3515
3894
  return editor;
3516
3895
  }
3517
3896
 
3897
+ function updateEditorDirty() {
3898
+ const el = document.getElementById('editor-view');
3899
+ if (!el) return;
3900
+ const currentContent = editor ? editor.getValue() : document.getElementById('editor-textarea').value;
3901
+ el.classList.toggle('editor-dirty', currentContent !== editorOriginalContent);
3902
+ }
3903
+
3518
3904
  function getCMmode(fileName) {
3519
3905
  const m = fileName.match(/\.([^.]+)$/);
3520
3906
  if (!m) return null;
@@ -3555,7 +3941,8 @@ async function openFileEditor(path) {
3555
3941
  const r = await api(`/api/files/read?path=${encodeURIComponent(path)}`);
3556
3942
  if (r.error) { toast(r.error, 'error'); return; }
3557
3943
  if (r.length > MAX_EDITOR_SIZE) {
3558
- if (!confirm(`File is ${(r.length / 1024 / 1024).toFixed(1)}MB. Open anyway?`)) return;
3944
+ const ok = await confirmDialog({ title: 'Large file', message: `File is ${(r.length / 1024 / 1024).toFixed(1)}MB. Open anyway?`, okText: 'Open', danger: true });
3945
+ if (!ok) return;
3559
3946
  }
3560
3947
  editorPath = path;
3561
3948
  editorOriginalContent = r.content;
@@ -3569,6 +3956,9 @@ async function openFileEditor(path) {
3569
3956
  cm.setOption('mode', getCMmode(fileName) || 'text/plain');
3570
3957
  document.querySelector('.CodeMirror').style.display = '';
3571
3958
  cm.refresh();
3959
+ const ta = document.getElementById('editor-textarea');
3960
+ ta.oninput = () => updateEditorDirty();
3961
+ updateEditorDirty();
3572
3962
 
3573
3963
  const preview = document.getElementById('editor-preview');
3574
3964
  const toggleBtn = document.getElementById('md-preview-toggle');
@@ -3584,7 +3974,7 @@ async function openFileEditor(path) {
3584
3974
  toggleBtn.style.display = 'none';
3585
3975
  if (refreshBtn) refreshBtn.style.display = 'none';
3586
3976
  }
3587
- document.getElementById('editor-filename').textContent = fileName + ' ' + path;
3977
+ document.getElementById('editor-filename').textContent = fileName + ' / ' + path;
3588
3978
  document.getElementById('editor-status').textContent = '';
3589
3979
 
3590
3980
  // Desktop: split layout; Mobile: overlay (handled by CSS)
@@ -3636,25 +4026,29 @@ async function openFileEditor(path) {
3636
4026
 
3637
4027
  async function saveFile() {
3638
4028
  if (!editorPath) { toast('No file open', 'error'); return; }
4029
+ setBtnBusy(document.getElementById('editor-save-btn'), true);
3639
4030
  const content = editor ? editor.getValue() : document.getElementById('editor-textarea').value;
3640
4031
  const r = await api('/api/files/write', {
3641
4032
  method: 'POST',
3642
4033
  headers: { 'Content-Type': 'application/json' },
3643
4034
  body: JSON.stringify({ path: editorPath, content })
3644
4035
  });
4036
+ setBtnBusy(document.getElementById('editor-save-btn'), false);
3645
4037
  if (r.success) {
3646
4038
  editorOriginalContent = content;
3647
- document.getElementById('editor-status').textContent = '✓ Saved';
4039
+ updateEditorDirty();
4040
+ document.getElementById('editor-status').textContent = 'Saved';
3648
4041
  toast('Saved', 'success');
3649
4042
  if (mdPreviewActive) refreshPreview();
3650
4043
  setTimeout(() => document.getElementById('editor-status').textContent = '', 2000);
3651
4044
  } else toast(r.error, 'error');
3652
4045
  }
3653
4046
 
3654
- function closeEditor() {
4047
+ async function closeEditor() {
3655
4048
  const currentContent = editor ? editor.getValue() : document.getElementById('editor-textarea').value;
3656
4049
  if (currentContent !== editorOriginalContent) {
3657
- if (!confirm('You have unsaved changes. Close anyway?')) return;
4050
+ const ok = await confirmDialog({ title: 'Unsaved changes', message: 'You have unsaved changes. Close anyway?', okText: 'Discard', cancelText: 'Keep Editing', danger: true });
4051
+ if (!ok) return;
3658
4052
  }
3659
4053
  clearPreviewLiveReload();
3660
4054
  document.getElementById('editor-view').classList.remove('open');
@@ -3986,6 +4380,33 @@ function loadSettings() {
3986
4380
 
3987
4381
  function saveSettings() { safeStorage.setItem('wt-settings', JSON.stringify(settings)); }
3988
4382
 
4383
+ const DEFAULT_SETTINGS = {
4384
+ theme: 'light', fontSize: 14, font: "'JetBrains Mono', 'SF Mono', 'Fira Code', Consolas, monospace",
4385
+ cursor: 'block', blink: true, scrollback: 5000, bell: false,
4386
+ mobilekeys: false, confirmclose: true, datasaver: false, autostart: false, keepAwake: false, termRightClick: true
4387
+ };
4388
+
4389
+ async function resetSettings() {
4390
+ const ok = await confirmDialog({ title: 'Reset settings', message: 'Reset all settings to defaults?', okText: 'Reset', danger: true });
4391
+ if (!ok) return;
4392
+ settings = { ...DEFAULT_SETTINGS };
4393
+ saveSettings();
4394
+ document.getElementById('s-theme').value = settings.theme;
4395
+ document.getElementById('s-fontsize').value = settings.fontSize;
4396
+ document.getElementById('s-scrollback').value = settings.scrollback;
4397
+ document.getElementById('s-font').value = settings.font;
4398
+ document.getElementById('s-cursor').value = settings.cursor;
4399
+ syncToggle('blink'); syncToggle('bell'); syncToggle('mobilekeys'); syncToggle('confirmclose'); syncToggle('datasaver'); syncToggle('termRightClick');
4400
+ applyTheme(settings.theme);
4401
+ applyFontSize(settings.fontSize);
4402
+ applyScrollback(settings.scrollback);
4403
+ if (settings.font) applyFont(settings.font);
4404
+ applyCursor(settings.cursor);
4405
+ const mk = document.getElementById('mobile-keys');
4406
+ if (mk) mk.style.display = settings.mobilekeys && window.innerWidth <= 768 ? 'flex' : 'none';
4407
+ toast('Settings reset', 'success');
4408
+ }
4409
+
3989
4410
  function saveSetting(k, v) { settings[k] = v; saveSettings(); }
3990
4411
 
3991
4412
  function syncToggle(k) {
@@ -4071,50 +4492,57 @@ function renderTunnels() {
4071
4492
  list.style.display = 'block';
4072
4493
  tunnelList.forEach(t => {
4073
4494
  const row = document.createElement('div');
4074
- row.style.cssText = 'padding:6px 0;border-bottom:1px solid var(--border)';
4495
+ row.className = 'tunnel-row';
4075
4496
  const label = document.createElement('div');
4076
- label.style.cssText = 'font-size:10px;color:var(--fg2);margin-bottom:3px';
4497
+ label.className = 'tunnel-label';
4077
4498
  label.textContent = t.localUrl;
4078
4499
  row.appendChild(label);
4079
4500
  const inner = document.createElement('div');
4080
- inner.style.cssText = 'display:flex;align-items:center;gap:5px;min-width:0';
4501
+ inner.className = 'tunnel-inner';
4081
4502
  const urlSpan = document.createElement('span');
4082
- urlSpan.style.cssText = 'font-size:11px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';
4503
+ urlSpan.className = 'tunnel-url';
4083
4504
  urlSpan.textContent = t.tunnelUrl;
4084
4505
  urlSpan.title = t.tunnelUrl;
4085
4506
  inner.appendChild(urlSpan);
4086
4507
  const status = document.createElement('span');
4087
- status.style.cssText = `font-size:9px;padding:1px 5px;border-radius:4px;flex-shrink:0;background:${t.alive ? 'var(--green)' : 'var(--yellow)'};color:var(--bg)`;
4508
+ status.className = 'tunnel-status';
4509
+ status.style.background = t.alive ? 'var(--green)' : 'var(--yellow)';
4510
+ status.style.color = 'var(--bg)';
4088
4511
  status.textContent = t.alive ? 'Live' : 'Orphan';
4089
4512
  status.title = t.alive ? 'cloudflared is running' : 'cloudflared process not found';
4090
4513
  inner.appendChild(status);
4091
4514
  if (t.alive && t.targetAlive !== undefined) {
4092
4515
  const targetStatus = document.createElement('span');
4093
4516
  const ok = t.targetAlive;
4094
- targetStatus.style.cssText = `font-size:9px;padding:1px 5px;border-radius:4px;flex-shrink:0;background:${ok ? 'var(--green)' : 'var(--red)'};color:var(--bg);opacity:0.8`;
4095
- targetStatus.textContent = ok ? '' : '';
4517
+ targetStatus.className = 'tunnel-target';
4518
+ targetStatus.style.background = ok ? 'var(--green)' : 'var(--red)';
4519
+ targetStatus.style.color = 'var(--bg)';
4520
+ targetStatus.style.opacity = '0.8';
4521
+ targetStatus.appendChild(mkSvg('9','9','0 0 24 24', ok
4522
+ ? '<polyline points="20 6 9 17 4 12"/>'
4523
+ : '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>'));
4096
4524
  targetStatus.title = ok ? `Target ${t.localUrl} is responding` : `Target ${t.localUrl} is not responding`;
4097
4525
  inner.appendChild(targetStatus);
4098
4526
  }
4099
4527
  const copyBtn = document.createElement('button');
4100
- copyBtn.className = 'icon-btn';
4101
- copyBtn.style.cssText = 'width:22px;height:22px;flex-shrink:0';
4528
+ copyBtn.className = 'icon-btn tunnel-btn';
4102
4529
  copyBtn.title = 'Copy';
4103
- copyBtn.appendChild(mkSvg('10','10','0 0 24 24','<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>'));
4530
+ copyBtn.setAttribute('aria-label', 'Copy tunnel URL');
4531
+ copyBtn.appendChild(mkSvg('12','12','0 0 24 24','<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>'));
4104
4532
  copyBtn.addEventListener('click', () => copyText(t.tunnelUrl));
4105
4533
  inner.appendChild(copyBtn);
4106
4534
  const openBtn = document.createElement('button');
4107
- openBtn.className = 'icon-btn';
4108
- openBtn.style.cssText = 'width:22px;height:22px;flex-shrink:0';
4535
+ openBtn.className = 'icon-btn tunnel-btn';
4109
4536
  openBtn.title = 'Open in new tab';
4110
- openBtn.appendChild(mkSvg('10','10','0 0 24 24','<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>'));
4537
+ openBtn.setAttribute('aria-label', 'Open tunnel in new tab');
4538
+ openBtn.appendChild(mkSvg('12','12','0 0 24 24','<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>'));
4111
4539
  openBtn.addEventListener('click', () => window.open(t.tunnelUrl, '_blank'));
4112
4540
  inner.appendChild(openBtn);
4113
4541
  const stopBtn = document.createElement('button');
4114
- stopBtn.className = 'icon-btn';
4115
- stopBtn.style.cssText = 'width:22px;height:22px;flex-shrink:0;color:var(--red)';
4542
+ stopBtn.className = 'icon-btn tunnel-btn stop';
4116
4543
  stopBtn.title = 'Stop';
4117
- stopBtn.appendChild(mkSvg('10','10','0 0 24 24','<rect x="6" y="6" width="12" height="12" rx="1"/>'));
4544
+ stopBtn.setAttribute('aria-label', 'Stop tunnel');
4545
+ stopBtn.appendChild(mkSvg('12','12','0 0 24 24','<rect x="6" y="6" width="12" height="12" rx="1"/>'));
4118
4546
  stopBtn.addEventListener('click', () => stopTunnelById(t.id));
4119
4547
  inner.appendChild(stopBtn);
4120
4548
  row.appendChild(inner);
@@ -4123,22 +4551,24 @@ function renderTunnels() {
4123
4551
  }
4124
4552
 
4125
4553
  async function createTunnel() {
4126
- const btn = document.querySelector('[onclick="createTunnel()"]');
4554
+ const btn = document.getElementById('tunnel-create-btn');
4127
4555
  if (btn && btn.dataset.busy === 'true') return;
4128
- if (btn) btn.dataset.busy = 'true';
4129
4556
  const url = document.getElementById('tunnel-url').value.trim();
4130
- if (!url) { if (btn) setTimeout(() => btn.dataset.busy = 'false', 2000); toast('Enter a local URL', 'error'); return; }
4557
+ if (!url) { showFieldError('tunnel-field-error', 'Enter a local URL'); return; }
4558
+ clearFieldError('tunnel-field-error');
4559
+ setBtnBusy(btn, true);
4131
4560
  document.getElementById('tunnel-status').textContent = 'Creating tunnel…';
4132
4561
  const r = await api('/api/tunnel', {
4133
4562
  method: 'POST',
4134
4563
  headers: { 'Content-Type': 'application/json' },
4135
4564
  body: JSON.stringify({ url })
4136
4565
  });
4566
+ setBtnBusy(btn, false);
4137
4567
  if (r.success) {
4138
4568
  tunnelList.push({ id: r.id, tunnelUrl: r.url, localUrl: url, alive: true, createdAt: Date.now() });
4139
4569
  renderTunnels();
4140
4570
  if (r.warning) {
4141
- document.getElementById('tunnel-status').textContent = '⚠️ ' + r.warning;
4571
+ document.getElementById('tunnel-status').textContent = r.warning;
4142
4572
  toast(r.warning, 'warning');
4143
4573
  } else {
4144
4574
  document.getElementById('tunnel-status').textContent = '';
@@ -4146,9 +4576,8 @@ async function createTunnel() {
4146
4576
  }
4147
4577
  } else {
4148
4578
  document.getElementById('tunnel-status').textContent = '';
4149
- toast(r.error || 'Failed to create tunnel', 'error');
4579
+ showFieldError('tunnel-field-error', r.error || 'Failed to create tunnel');
4150
4580
  }
4151
- if (btn) setTimeout(() => btn.dataset.busy = 'false', 2000);
4152
4581
  }
4153
4582
 
4154
4583
  async function stopTunnelById(id) {
@@ -4186,7 +4615,7 @@ function applyTheme(theme, save = true) {
4186
4615
  document.documentElement.dataset.theme = resolved;
4187
4616
  settings.theme = theme;
4188
4617
  document.getElementById('s-theme').value = theme;
4189
- tabs.forEach(t => { if (t.term) { t.term.options.theme = getXtermTheme(); } });
4618
+ tabs.forEach(t => { if (t.term) { t.term.options.theme = getXtermTheme(); t.term.options.selectionTheme = getXtermSelectionTheme(); } });
4190
4619
  const themeColor = getComputedStyle(document.body).getPropertyValue('--bg2').trim();
4191
4620
  document.querySelector('meta[name="theme-color"]').content = themeColor;
4192
4621
  if (save) saveSettings();
@@ -4241,6 +4670,64 @@ function toggleSidebar() {
4241
4670
  }
4242
4671
  }
4243
4672
 
4673
+ let moreMenuOpen = false;
4674
+ function closeMoreMenu() {
4675
+ const menu = document.getElementById('more-menu');
4676
+ const btn = document.getElementById('more-btn');
4677
+ if (menu) menu.classList.remove('open');
4678
+ if (btn) btn.classList.remove('active');
4679
+ document.removeEventListener('click', closeMoreMenuOnClickOutside, true);
4680
+ moreMenuOpen = false;
4681
+ }
4682
+ function closeMoreMenuOnClickOutside(e) {
4683
+ const menu = document.getElementById('more-menu');
4684
+ const btn = document.getElementById('more-btn');
4685
+ if (menu && menu.contains(e.target)) return;
4686
+ if (btn && btn.contains(e.target)) return;
4687
+ closeMoreMenu();
4688
+ }
4689
+ function toggleMoreMenu() {
4690
+ if (moreMenuOpen) { closeMoreMenu(); return; }
4691
+ moreMenuOpen = true;
4692
+ const menu = document.getElementById('more-menu');
4693
+ const btn = document.getElementById('more-btn');
4694
+ if (menu) menu.classList.add('open');
4695
+ if (btn) btn.classList.add('active');
4696
+ setTimeout(() => {
4697
+ document.addEventListener('click', closeMoreMenuOnClickOutside, true);
4698
+ const first = menu && menu.querySelector('.mm-item');
4699
+ if (first) first.focus();
4700
+ }, 0);
4701
+ }
4702
+
4703
+ function setupMoreMenuKeyboard() {
4704
+ const menu = document.getElementById('more-menu');
4705
+ if (!menu || menu.dataset._kbSetup) return;
4706
+ menu.dataset._kbSetup = '1';
4707
+ menu.querySelectorAll('.mm-item').forEach(el => { if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '-1'); });
4708
+ menu.addEventListener('keydown', e => {
4709
+ const items = [...menu.querySelectorAll('.mm-item')].filter(el => el.offsetParent !== null);
4710
+ if (!items.length) return;
4711
+ let idx = items.indexOf(document.activeElement);
4712
+ switch (e.key) {
4713
+ case 'Escape': e.preventDefault(); closeMoreMenu(); return;
4714
+ case 'ArrowDown': idx = (idx + 1) % items.length; break;
4715
+ case 'ArrowUp': idx = (idx - 1 + items.length) % items.length; break;
4716
+ case 'Home': idx = 0; break;
4717
+ case 'End': idx = items.length - 1; break;
4718
+ case 'Tab': {
4719
+ const last = document.activeElement === items[items.length - 1];
4720
+ if (last && !e.shiftKey) { e.preventDefault(); closeMoreMenu(); return; }
4721
+ if (document.activeElement === items[0] && e.shiftKey) { e.preventDefault(); closeMoreMenu(); return; }
4722
+ return;
4723
+ }
4724
+ default: return;
4725
+ }
4726
+ e.preventDefault();
4727
+ if (idx >= 0 && items[idx]) items[idx].focus();
4728
+ });
4729
+ }
4730
+
4244
4731
  function updateSidebarNarrowClass() {
4245
4732
  const sb = document.getElementById('sidebar');
4246
4733
  if (!sb) return;
@@ -4474,9 +4961,8 @@ function moveTabToEnd(fromId) {
4474
4961
  if (fromIdx === -1) return;
4475
4962
  const [moved] = tabs.splice(fromIdx, 1);
4476
4963
  tabs.push(moved);
4477
- const bar = document.getElementById('tab-bar');
4478
- const newTabBtn = document.getElementById('new-tab-btn');
4479
- if (moved.el && moved.el.parentNode === bar) bar.insertBefore(moved.el, newTabBtn);
4964
+ const bar = document.getElementById('tab-scroll');
4965
+ if (moved.el && moved.el.parentNode === bar) bar.appendChild(moved.el);
4480
4966
  saveTabState();
4481
4967
  }
4482
4968
 
@@ -4521,7 +5007,7 @@ function setupKeyboardShortcuts() {
4521
5007
  if (e.key === 'Escape') {
4522
5008
  const termMenu = document.getElementById('term-ctx-menu');
4523
5009
  if (termMenu && termMenu.style.display !== 'none') {
4524
- termMenu.style.display = 'none';
5010
+ hideTermCtxMenu();
4525
5011
  return;
4526
5012
  }
4527
5013
  if (document.getElementById('ctx-menu').classList.contains('open')) {
@@ -4538,6 +5024,7 @@ function setupKeyboardShortcuts() {
4538
5024
  else if (id === 'newfolder-overlay' || id === 'newfile-overlay' || id === 'rename-overlay') closeOverlay(id);
4539
5025
  else if (id === 'image-viewer') closeImageViewer();
4540
5026
  else if (id === 'conflict-overlay') closeOverlay(id);
5027
+ else closeOverlay(id);
4541
5028
  });
4542
5029
  }
4543
5030
  });
@@ -4900,6 +5387,9 @@ function setupDragDrop() {
4900
5387
  const traversed = await traverseDirectoryEntry(entry);
4901
5388
  filesToUpload.push(...traversed);
4902
5389
  } catch(e) { console.warn(e); }
5390
+ } else {
5391
+ const file = item.getAsFile();
5392
+ if (file) filesToUpload.push({ file, path: file.name });
4903
5393
  }
4904
5394
  }
4905
5395
  }
@@ -4918,10 +5408,23 @@ function setupDragDrop() {
4918
5408
  // ═══════════════════════════════════════════════════════
4919
5409
  let lastFocusedElement = null;
4920
5410
  let _focusTrapHandler = null;
5411
+ function openShortcuts() {
5412
+ openOverlay('shortcuts-overlay');
5413
+ document.getElementById('shortcuts-overlay').querySelector('.btn').focus();
5414
+ }
4921
5415
  function openOverlay(id) {
4922
5416
  lastFocusedElement = document.activeElement;
4923
5417
  const overlay = document.getElementById(id);
4924
5418
  overlay.classList.add('open');
5419
+ overlay.setAttribute('role', 'dialog');
5420
+ overlay.setAttribute('aria-modal', 'true');
5421
+ const titleEl = overlay.querySelector('h2');
5422
+ if (titleEl) {
5423
+ if (!titleEl.id) titleEl.id = id + '-title';
5424
+ overlay.setAttribute('aria-labelledby', titleEl.id);
5425
+ } else if (overlay.getAttribute('aria-label')) {
5426
+ overlay.removeAttribute('aria-labelledby');
5427
+ }
4925
5428
  const modal = overlay.querySelector('.modal, input, button');
4926
5429
  if (modal) setTimeout(() => modal.focus(), 50);
4927
5430
  installFocusTrap(overlay);
@@ -4960,24 +5463,89 @@ function removeFocusTrap() {
4960
5463
  }
4961
5464
  }
4962
5465
 
5466
+ let _confirmState = null;
5467
+ function confirmDialog({ title = 'Confirm', message = '', okText = 'OK', cancelText = 'Cancel', danger = false } = {}) {
5468
+ return new Promise(resolve => {
5469
+ const ov = document.getElementById('confirm-overlay');
5470
+ if (!ov || !ov.parentNode) { resolve(false); return; }
5471
+ const okBtn = document.getElementById('confirm-ok-btn');
5472
+ const cancelBtn = document.getElementById('confirm-cancel-btn');
5473
+ const titleEl = document.getElementById('confirm-title');
5474
+ const msgEl = document.getElementById('confirm-msg');
5475
+ titleEl.textContent = title;
5476
+ msgEl.textContent = message;
5477
+ ov.setAttribute('role', 'dialog');
5478
+ ov.setAttribute('aria-modal', 'true');
5479
+ ov.setAttribute('aria-labelledby', 'confirm-title');
5480
+ okBtn.textContent = okText;
5481
+ cancelBtn.textContent = cancelText;
5482
+ okBtn.classList.toggle('btn-danger', !!danger);
5483
+ let settled = false;
5484
+ const cleanup = () => {
5485
+ if (settled) return;
5486
+ settled = true;
5487
+ _confirmState = null;
5488
+ ov.classList.remove('open');
5489
+ removeFocusTrap();
5490
+ if (lastFocusedElement) { setTimeout(() => lastFocusedElement?.focus(), 50); lastFocusedElement = null; }
5491
+ };
5492
+ const finish = val => { const cb = _confirmState && _confirmState.resolve; cleanup(); if (cb) cb(val); };
5493
+ const onKey = e => {
5494
+ if (e.key === 'Escape') { e.stopPropagation(); finish(false); }
5495
+ else if (e.key === 'Enter' && !(e.target instanceof HTMLButtonElement)) { e.preventDefault(); finish(true); }
5496
+ };
5497
+ _confirmState = { resolve, cleanup };
5498
+ lastFocusedElement = document.activeElement;
5499
+ okBtn.onclick = () => finish(true);
5500
+ cancelBtn.onclick = () => finish(false);
5501
+ ov.addEventListener('keydown', onKey);
5502
+ ov.classList.add('open');
5503
+ installFocusTrap(ov);
5504
+ setTimeout(() => okBtn.focus(), 50);
5505
+ });
5506
+ }
5507
+
4963
5508
  document.querySelectorAll('.overlay').forEach(o => {
4964
5509
  o.addEventListener('click', e => {
4965
5510
  if (e.target !== o) return;
4966
5511
  if (o.id === 'sys-overlay') { closeSystemStats(); return; }
4967
5512
  if (o.id === 'finder-overlay') { clearTimeout(finderTimer); }
5513
+ if (o.id === 'confirm-overlay') return; // dismiss handled by confirmDialog only
4968
5514
  closeOverlay(o.id);
4969
5515
  });
4970
5516
  });
4971
5517
 
5518
+ function setBtnBusy(btn, busy) {
5519
+ if (!btn) return;
5520
+ if (busy) { btn.dataset.busy = 'true'; btn.classList.add('loading'); btn.disabled = true; }
5521
+ else { btn.dataset.busy = 'false'; btn.classList.remove('loading'); btn.disabled = false; }
5522
+ }
5523
+
5524
+ function showFieldError(id, msg) {
5525
+ const el = document.getElementById(id);
5526
+ if (el) { el.textContent = msg || ''; el.classList.toggle('show', !!msg); }
5527
+ }
5528
+ function clearFieldError(id) { showFieldError(id, ''); }
5529
+
5530
+ const TOAST_ICONS = {
5531
+ success: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>',
5532
+ error: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
5533
+ warning: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
5534
+ info: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>'
5535
+ };
5536
+
4972
5537
  function toast(msg, type = 'info') {
4973
5538
  const el = document.createElement('div');
4974
5539
  el.className = `toast ${type}`;
4975
- el.textContent = msg;
5540
+ el.innerHTML = (TOAST_ICONS[type] || '') + '<span></span>';
5541
+ el.querySelector('span').textContent = msg;
4976
5542
  const mk = document.getElementById('mobile-keys');
4977
5543
  if (mk && mk.style.display !== 'none' && window.getComputedStyle(mk).display !== 'none') {
4978
5544
  el.style.marginBottom = 'var(--mobilekey-h)';
4979
5545
  }
4980
- document.getElementById('toast-container').appendChild(el);
5546
+ const container = document.getElementById('toast-container');
5547
+ container.appendChild(el);
5548
+ while (container.children.length > 4) container.firstChild.remove();
4981
5549
  setTimeout(() => el.remove(), 3000);
4982
5550
  return el;
4983
5551
  }
@@ -4987,6 +5555,7 @@ function updateConnStatus(connected) {
4987
5555
  if (dot) {
4988
5556
  dot.style.background = connected ? 'var(--green)' : 'var(--red)';
4989
5557
  dot.title = connected ? 'Connected' : 'Disconnected';
5558
+ dot.setAttribute('aria-label', connected ? 'Connected' : 'Disconnected');
4990
5559
  }
4991
5560
  }
4992
5561
 
@@ -5028,11 +5597,14 @@ function setupSwipeGestures() {
5028
5597
  let pullStartY = 0;
5029
5598
  let pullOffset = 0;
5030
5599
  const fileListWrap = document.getElementById('file-list-wrap');
5031
- if (fileListWrap) {
5600
+ const ptr = document.getElementById('ptr-indicator');
5601
+ if (fileListWrap && ptr) {
5032
5602
  fileListWrap.addEventListener('touchstart', e => {
5033
5603
  if (fileListWrap.scrollTop <= 0) {
5034
5604
  pullStartY = e.touches[0].clientY;
5035
5605
  pullOffset = 0;
5606
+ ptr.style.opacity = '0';
5607
+ ptr.classList.remove('release');
5036
5608
  }
5037
5609
  }, { passive: true });
5038
5610
  fileListWrap.addEventListener('touchmove', e => {
@@ -5040,16 +5612,21 @@ function setupSwipeGestures() {
5040
5612
  const dy = e.touches[0].clientY - pullStartY;
5041
5613
  if (dy > 0 && fileListWrap.scrollTop <= 0) {
5042
5614
  pullOffset = dy;
5043
- fileListWrap.style.transform = `translateY(${Math.min(dy * 0.4, 60)}px)`;
5615
+ const t = Math.min(dy * 0.4, 60);
5616
+ fileListWrap.style.transform = `translateY(${t}px)`;
5044
5617
  fileListWrap.style.transition = 'none';
5618
+ ptr.style.opacity = Math.min(1, dy / 40).toFixed(2);
5619
+ ptr.classList.toggle('release', pullOffset > 60);
5045
5620
  }
5046
5621
  }, { passive: true });
5047
5622
  fileListWrap.addEventListener('touchend', () => {
5048
5623
  if (pullOffset > 60) {
5624
+ ptr.classList.remove('release');
5049
5625
  refreshFiles();
5050
5626
  }
5051
5627
  fileListWrap.style.transition = 'transform 0.3s ease';
5052
5628
  fileListWrap.style.transform = '';
5629
+ ptr.style.opacity = '0';
5053
5630
  pullStartY = 0;
5054
5631
  pullOffset = 0;
5055
5632
  setTimeout(() => { fileListWrap.style.transition = ''; }, 300);
@@ -5141,7 +5718,9 @@ function renderBookmarks() {
5141
5718
  div.appendChild(bmPath);
5142
5719
  const rmBtn = document.createElement('span');
5143
5720
  rmBtn.className = 'bm-remove';
5144
- rmBtn.textContent = '×';
5721
+ rmBtn.setAttribute('role', 'button');
5722
+ rmBtn.setAttribute('aria-label', 'Remove bookmark');
5723
+ rmBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
5145
5724
  rmBtn.title = 'Remove';
5146
5725
  rmBtn.addEventListener('click', e => {
5147
5726
  e.stopPropagation();
@@ -5314,7 +5893,9 @@ function renderCmdLib(filter) {
5314
5893
  cmd.textContent = c.cmd;
5315
5894
  const runBtn = document.createElement('button');
5316
5895
  runBtn.className = 'cmd-lib-run';
5317
- runBtn.title = 'Run in terminal';
5896
+ runBtn.title = 'Run in terminal';
5897
+ runBtn.setAttribute('aria-label', 'Run in terminal');
5898
+ runBtn.setAttribute('aria-label', 'Run in terminal');
5318
5899
  runBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="6,3 20,12 6,21"/></svg>';
5319
5900
  runBtn.addEventListener('click', e => {
5320
5901
  e.stopPropagation();
@@ -5328,7 +5909,8 @@ function renderCmdLib(filter) {
5328
5909
  const delBtn = document.createElement('button');
5329
5910
  delBtn.className = 'cmd-lib-del';
5330
5911
  delBtn.title = 'Delete';
5331
- delBtn.textContent = '×';
5912
+ delBtn.setAttribute('aria-label', 'Delete command');
5913
+ delBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
5332
5914
  delBtn.addEventListener('click', e => {
5333
5915
  e.stopPropagation();
5334
5916
  deleteCustomCmd(c.name, c.cmd);
@@ -5340,7 +5922,7 @@ function renderCmdLib(filter) {
5340
5922
  const tab = getActiveTab();
5341
5923
  if (!tab?.term) return;
5342
5924
  tab.term.focus();
5343
- document.execCommand('insertText', false, c.cmd);
5925
+ tab.term.paste(c.cmd);
5344
5926
  toggleCmdLib();
5345
5927
  });
5346
5928
  section.appendChild(item);
@@ -5536,7 +6118,8 @@ async function renderCmdHist() {
5536
6118
  const delBtn = document.createElement('button');
5537
6119
  delBtn.className = 'cmd-hist-del';
5538
6120
  delBtn.title = 'Remove';
5539
- delBtn.textContent = '×';
6121
+ delBtn.setAttribute('aria-label', 'Remove from history');
6122
+ delBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
5540
6123
  delBtn.addEventListener('click', e => { e.stopPropagation(); removeCmdHistItem(realIdx); });
5541
6124
  item.appendChild(delBtn);
5542
6125
 
@@ -5544,7 +6127,7 @@ async function renderCmdHist() {
5544
6127
  const tab = getActiveTab();
5545
6128
  if (!tab?.term) return;
5546
6129
  tab.term.focus();
5547
- document.execCommand('insertText', false, h.cmd);
6130
+ tab.term.paste(h.cmd);
5548
6131
  toggleCmdLib();
5549
6132
  });
5550
6133
  list.appendChild(item);
@@ -5677,6 +6260,10 @@ function showInstallBanner(isIOS) {
5677
6260
  el.querySelector('.btn-primary').style.display = 'none';
5678
6261
  el.querySelector('.btn-ghost').textContent = 'Dismiss';
5679
6262
  }
6263
+ const mk = document.getElementById('mobile-keys');
6264
+ if (mk && window.getComputedStyle(mk).display !== 'none') {
6265
+ el.style.bottom = 'var(--mobilekey-h)';
6266
+ }
5680
6267
  el.style.display = 'flex';
5681
6268
  document.getElementById('toast-container').classList.add('banner-visible');
5682
6269
  }