ai-or-die 0.1.68 → 0.1.70
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/package.json +1 -1
- package/src/public/app.js +179 -6
- package/src/public/components/bottom-nav.css +1 -1
- package/src/public/components/buttons.css +4 -2
- package/src/public/components/menus.css +1 -1
- package/src/public/components/safe-area.css +68 -0
- package/src/public/extra-keys.js +6 -6
- package/src/public/file-browser.js +59 -1
- package/src/public/generic-drop-handler.js +35 -0
- package/src/public/image-handler.js +54 -0
- package/src/public/index.html +22 -2
- package/src/public/mobile.css +26 -4
- package/src/public/tokens.css +11 -0
- package/src/server.js +64 -4
- package/src/utils/file-utils.js +16 -2
package/package.json
CHANGED
package/src/public/app.js
CHANGED
|
@@ -194,6 +194,7 @@ class ClaudeCodeWebInterface {
|
|
|
194
194
|
this.setupTerminal();
|
|
195
195
|
this._setupExtraKeys();
|
|
196
196
|
this._setupOrientationHandler();
|
|
197
|
+
this._setupPwaStandaloneListener();
|
|
197
198
|
this.setupUI();
|
|
198
199
|
if (this.voiceInputConfig) this.setupVoiceInput();
|
|
199
200
|
this.setupPlanDetector();
|
|
@@ -691,7 +692,12 @@ class ClaudeCodeWebInterface {
|
|
|
691
692
|
caption: imageData.caption || ''
|
|
692
693
|
});
|
|
693
694
|
}
|
|
694
|
-
}
|
|
695
|
+
},
|
|
696
|
+
// Non-image files pasted from a file manager (clipboardData
|
|
697
|
+
// carries File objects) — route through the generic pipeline.
|
|
698
|
+
// Fires only AFTER the image branch declines, so image paste
|
|
699
|
+
// precedence is unchanged.
|
|
700
|
+
onFilesPaste: (files) => this._attachFiles(files)
|
|
695
701
|
}
|
|
696
702
|
);
|
|
697
703
|
}
|
|
@@ -827,6 +833,7 @@ class ClaudeCodeWebInterface {
|
|
|
827
833
|
const handleOrientationChange = () => {
|
|
828
834
|
setTimeout(() => {
|
|
829
835
|
this.fitTerminal();
|
|
836
|
+
this._polyfillSafeAreaInsets();
|
|
830
837
|
// Re-evaluate keyboard state
|
|
831
838
|
if (window.visualViewport && this._keyboardOpen !== undefined) {
|
|
832
839
|
const heightDiff = window.innerHeight - window.visualViewport.height;
|
|
@@ -1091,9 +1098,20 @@ class ClaudeCodeWebInterface {
|
|
|
1091
1098
|
}, 3000);
|
|
1092
1099
|
};
|
|
1093
1100
|
|
|
1094
|
-
// Attach
|
|
1101
|
+
// Attach File button (id is historical: attachImageBtn). Opens a picker
|
|
1102
|
+
// for ANY file type — images go through the preview flow, other files
|
|
1103
|
+
// upload to .claude-attachments/ and inject `@<path>`.
|
|
1095
1104
|
const attachBtn = document.getElementById('attachImageBtn');
|
|
1096
|
-
if (attachBtn && window.
|
|
1105
|
+
if (attachBtn && window.genericDropHandler
|
|
1106
|
+
&& typeof window.genericDropHandler.triggerFilePicker === 'function') {
|
|
1107
|
+
attachBtn.addEventListener('click', () => {
|
|
1108
|
+
window.genericDropHandler.triggerFilePicker(
|
|
1109
|
+
(files) => this._attachFiles(files),
|
|
1110
|
+
{ multiple: true }
|
|
1111
|
+
);
|
|
1112
|
+
});
|
|
1113
|
+
} else if (attachBtn && window.imageHandler) {
|
|
1114
|
+
// Fallback: generic handler unavailable — keep the legacy image-only picker.
|
|
1097
1115
|
attachBtn.addEventListener('click', () => {
|
|
1098
1116
|
window.imageHandler.triggerFilePicker((imageData) => {
|
|
1099
1117
|
this._pendingImageCaption = imageData.caption;
|
|
@@ -2164,6 +2182,16 @@ class ClaudeCodeWebInterface {
|
|
|
2164
2182
|
this._sessionWorkingDirs.set(message.sessionId, message.workingDir);
|
|
2165
2183
|
}
|
|
2166
2184
|
this.updateSessionButton(message.sessionName);
|
|
2185
|
+
|
|
2186
|
+
// Re-root the (singleton) file-browser panel to the newly
|
|
2187
|
+
// active session's dir. open() short-circuits when already
|
|
2188
|
+
// open, so without this an open panel would keep showing the
|
|
2189
|
+
// previous tab's directory after a tab switch. Fires only when
|
|
2190
|
+
// the panel is open and the session actually changed.
|
|
2191
|
+
if (this._fileBrowserPanel &&
|
|
2192
|
+
typeof this._fileBrowserPanel.notifyActiveSessionChanged === 'function') {
|
|
2193
|
+
this._fileBrowserPanel.notifyActiveSessionChanged(message.sessionId);
|
|
2194
|
+
}
|
|
2167
2195
|
|
|
2168
2196
|
// Update tab status
|
|
2169
2197
|
if (this.sessionTabManager) {
|
|
@@ -3419,7 +3447,7 @@ class ClaudeCodeWebInterface {
|
|
|
3419
3447
|
}
|
|
3420
3448
|
} else {
|
|
3421
3449
|
if (activeTerminal) {
|
|
3422
|
-
activeTerminal.write('\r\n\x1b[33mImage paste requires HTTPS. Use Attach
|
|
3450
|
+
activeTerminal.write('\r\n\x1b[33mImage paste requires HTTPS. Use Attach File instead.\x1b[0m\r\n');
|
|
3423
3451
|
}
|
|
3424
3452
|
}
|
|
3425
3453
|
} catch (err) {
|
|
@@ -3428,8 +3456,15 @@ class ClaudeCodeWebInterface {
|
|
|
3428
3456
|
break;
|
|
3429
3457
|
}
|
|
3430
3458
|
case 'attachImage': {
|
|
3431
|
-
|
|
3432
|
-
if (window.
|
|
3459
|
+
// Generalized to any file type (action id is historical).
|
|
3460
|
+
if (window.genericDropHandler
|
|
3461
|
+
&& typeof window.genericDropHandler.triggerFilePicker === 'function') {
|
|
3462
|
+
window.genericDropHandler.triggerFilePicker(
|
|
3463
|
+
(files) => this._attachFiles(files),
|
|
3464
|
+
{ multiple: true }
|
|
3465
|
+
);
|
|
3466
|
+
} else if (window.imageHandler) {
|
|
3467
|
+
const attachSocket = activeSocket;
|
|
3433
3468
|
window.imageHandler.triggerFilePicker((imageData) => {
|
|
3434
3469
|
this._pendingImageCaption = imageData.caption;
|
|
3435
3470
|
const msg = JSON.stringify({
|
|
@@ -3660,6 +3695,140 @@ class ClaudeCodeWebInterface {
|
|
|
3660
3695
|
|| navigator.standalone === true;
|
|
3661
3696
|
}
|
|
3662
3697
|
|
|
3698
|
+
// Shared attachment router for the non-drop surfaces (attach button,
|
|
3699
|
+
// context-menu, paste). Partitions the selected files: anything that passes
|
|
3700
|
+
// the image allowlist goes through the EXISTING image preview → image_upload
|
|
3701
|
+
// path (unchanged), everything else is routed to the generic drop pipeline
|
|
3702
|
+
// (upload to .claude-attachments/ + `@<path>` injection). Drag-and-drop does
|
|
3703
|
+
// NOT use this — it already partitions internally in generic-drop-handler.
|
|
3704
|
+
_attachFiles(files) {
|
|
3705
|
+
if (!files) return;
|
|
3706
|
+
const list = Array.prototype.slice.call(files);
|
|
3707
|
+
if (!list.length) return;
|
|
3708
|
+
const ih = window.imageHandler;
|
|
3709
|
+
const isImg = (f) => !!(ih && typeof ih.isAcceptedImageType === 'function'
|
|
3710
|
+
&& ih.isAcceptedImageType(f.type));
|
|
3711
|
+
const images = list.filter(isImg);
|
|
3712
|
+
const others = list.filter((f) => !isImg(f));
|
|
3713
|
+
|
|
3714
|
+
// Capture the socket at attach-initiation time. The image preview modal
|
|
3715
|
+
// is async (user-driven); the active session/socket can change while it
|
|
3716
|
+
// is open. Sending on a captured target avoids the upload landing on a
|
|
3717
|
+
// different session (mirrors the context-menu's existing capture).
|
|
3718
|
+
const targetSocket = this.socket;
|
|
3719
|
+
|
|
3720
|
+
// Images: reuse the existing single-preview flow. The modal handles one
|
|
3721
|
+
// image at a time, so if several images are selected we attach the first
|
|
3722
|
+
// and tell the user rather than silently dropping the rest.
|
|
3723
|
+
if (images.length && ih && typeof ih.showImagePreview === 'function') {
|
|
3724
|
+
if (images.length > 1 && window.feedback && typeof window.feedback.info === 'function') {
|
|
3725
|
+
window.feedback.info('Only the first image is attached — attach images one at a time.');
|
|
3726
|
+
}
|
|
3727
|
+
ih.showImagePreview(images[0], (imageData) => {
|
|
3728
|
+
this._pendingImageCaption = imageData.caption;
|
|
3729
|
+
if (targetSocket && targetSocket.readyState === WebSocket.OPEN) {
|
|
3730
|
+
targetSocket.send(JSON.stringify({
|
|
3731
|
+
type: 'image_upload',
|
|
3732
|
+
base64: imageData.base64,
|
|
3733
|
+
mimeType: imageData.mimeType,
|
|
3734
|
+
fileName: imageData.fileName || 'attached-image.png',
|
|
3735
|
+
caption: imageData.caption || ''
|
|
3736
|
+
}));
|
|
3737
|
+
}
|
|
3738
|
+
});
|
|
3739
|
+
}
|
|
3740
|
+
|
|
3741
|
+
// Non-images: shared generic pipeline (upload + @path inject).
|
|
3742
|
+
if (others.length && this._genericDropHandler
|
|
3743
|
+
&& typeof this._genericDropHandler.dispatchFiles === 'function') {
|
|
3744
|
+
this._genericDropHandler.dispatchFiles(others);
|
|
3745
|
+
} else if (others.length && window.feedback && typeof window.feedback.error === 'function') {
|
|
3746
|
+
window.feedback.error('File upload is not available right now.');
|
|
3747
|
+
}
|
|
3748
|
+
}
|
|
3749
|
+
|
|
3750
|
+
_setupPwaStandaloneListener() {
|
|
3751
|
+
const apply = () => {
|
|
3752
|
+
const standalone = this._isInstalledPWA();
|
|
3753
|
+
document.documentElement.classList.toggle('pwa-standalone', standalone);
|
|
3754
|
+
if (standalone) this._polyfillSafeAreaInsets();
|
|
3755
|
+
};
|
|
3756
|
+
apply();
|
|
3757
|
+
try {
|
|
3758
|
+
const queries = [
|
|
3759
|
+
'(display-mode: standalone)',
|
|
3760
|
+
'(display-mode: fullscreen)',
|
|
3761
|
+
'(display-mode: minimal-ui)',
|
|
3762
|
+
'(display-mode: window-controls-overlay)',
|
|
3763
|
+
];
|
|
3764
|
+
queries.forEach((q) => {
|
|
3765
|
+
const mql = window.matchMedia(q);
|
|
3766
|
+
if (mql.addEventListener) mql.addEventListener('change', apply);
|
|
3767
|
+
else if (mql.addListener) mql.addListener(apply);
|
|
3768
|
+
});
|
|
3769
|
+
} catch (_) { /* ignore */ }
|
|
3770
|
+
}
|
|
3771
|
+
|
|
3772
|
+
_polyfillSafeAreaInsets() {
|
|
3773
|
+
// WebKit bug: env(safe-area-inset-top|bottom) sometimes returns 0px in
|
|
3774
|
+
// iOS PWA standalone on Dynamic Island / notched devices. Probe the
|
|
3775
|
+
// actual env() value per axis; if zero on a notch-class iOS device in
|
|
3776
|
+
// portrait, substitute the known defaults (59px island, 34px home
|
|
3777
|
+
// indicator). Results are exposed as the --safe-area-inset-* variables
|
|
3778
|
+
// that tokens.css (--sa-top/--sa-bottom) and the gated rules consume.
|
|
3779
|
+
if (!document.body) return;
|
|
3780
|
+
|
|
3781
|
+
const root = document.documentElement;
|
|
3782
|
+
|
|
3783
|
+
// Only ever set fake insets in installed-PWA standalone mode. In a
|
|
3784
|
+
// normal browser tab env() is authoritative (and legitimately 0 at the
|
|
3785
|
+
// top), so forcing a fallback there would push content down for no
|
|
3786
|
+
// reason — and the converted `var(--sa-*)` consumers are ungated, so a
|
|
3787
|
+
// stray value WOULD change non-PWA layout. Clear and bail when not PWA.
|
|
3788
|
+
if (!this._isInstalledPWA()) {
|
|
3789
|
+
root.style.removeProperty('--safe-area-inset-top');
|
|
3790
|
+
root.style.removeProperty('--safe-area-inset-bottom');
|
|
3791
|
+
return;
|
|
3792
|
+
}
|
|
3793
|
+
|
|
3794
|
+
const measure = (axis) => {
|
|
3795
|
+
const probe = document.createElement('div');
|
|
3796
|
+
probe.style.cssText = 'position:fixed;left:0;width:1px;pointer-events:none;visibility:hidden;'
|
|
3797
|
+
+ (axis === 'top' ? 'top:0;' : 'bottom:0;')
|
|
3798
|
+
+ 'height:env(safe-area-inset-' + axis + ');';
|
|
3799
|
+
document.body.appendChild(probe);
|
|
3800
|
+
const px = probe.offsetHeight;
|
|
3801
|
+
document.body.removeChild(probe);
|
|
3802
|
+
return px;
|
|
3803
|
+
};
|
|
3804
|
+
|
|
3805
|
+
const isPortrait = window.matchMedia('(orientation: portrait)').matches;
|
|
3806
|
+
// Notch / Dynamic-Island iPhones are tall (aspect > 2); home-button
|
|
3807
|
+
// iPhones (SE, 8) are ~1.78 and iPads ~1.33. Only those tall devices
|
|
3808
|
+
// have a top cutout + bottom home indicator, so only they get the
|
|
3809
|
+
// fallback when env() reports a (buggy) 0. This keeps the SE/iPad from
|
|
3810
|
+
// gaining a phantom inset.
|
|
3811
|
+
const longSide = Math.max(window.innerWidth, window.innerHeight);
|
|
3812
|
+
const shortSide = Math.min(window.innerWidth, window.innerHeight);
|
|
3813
|
+
const tall = shortSide > 0 && (longSide / shortSide) > 2;
|
|
3814
|
+
const notchClass = isPortrait && this._isIOS() && tall;
|
|
3815
|
+
|
|
3816
|
+
const apply = (axis, fallback) => {
|
|
3817
|
+
const measured = measure(axis);
|
|
3818
|
+
const prop = '--safe-area-inset-' + axis;
|
|
3819
|
+
if (measured > 0) {
|
|
3820
|
+
root.style.setProperty(prop, measured + 'px');
|
|
3821
|
+
} else if (notchClass) {
|
|
3822
|
+
root.style.setProperty(prop, fallback);
|
|
3823
|
+
} else {
|
|
3824
|
+
root.style.removeProperty(prop);
|
|
3825
|
+
}
|
|
3826
|
+
};
|
|
3827
|
+
|
|
3828
|
+
apply('top', '59px');
|
|
3829
|
+
apply('bottom', '34px');
|
|
3830
|
+
}
|
|
3831
|
+
|
|
3663
3832
|
_isIOS() {
|
|
3664
3833
|
return /iPad|iPhone|iPod/.test(navigator.userAgent)
|
|
3665
3834
|
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
@@ -3979,6 +4148,10 @@ class ClaudeCodeWebInterface {
|
|
|
3979
4148
|
// opens picks up the new cwd (per ADR-0016 / task #14).
|
|
3980
4149
|
initialPath: this.getCurrentWorkingDir(),
|
|
3981
4150
|
getCwd: () => this.getCurrentWorkingDir(),
|
|
4151
|
+
// Active session id → sent as ?session on /api/files so the
|
|
4152
|
+
// server can resolve the per-tab default root even when the
|
|
4153
|
+
// client cwd cache is cold (e.g. just after a page reload).
|
|
4154
|
+
getSessionId: () => this.currentClaudeSessionId,
|
|
3982
4155
|
});
|
|
3983
4156
|
}
|
|
3984
4157
|
return this._fileBrowserPanel;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
background: var(--surface-secondary);
|
|
9
9
|
border-top: 1px solid var(--border-default);
|
|
10
10
|
padding: 0 8px;
|
|
11
|
-
padding-bottom:
|
|
11
|
+
padding-bottom: var(--sa-bottom);
|
|
12
12
|
z-index: var(--z-sticky);
|
|
13
13
|
align-items: center;
|
|
14
14
|
justify-content: space-around;
|
|
@@ -145,7 +145,9 @@
|
|
|
145
145
|
position: fixed;
|
|
146
146
|
bottom: 80px;
|
|
147
147
|
right: 20px;
|
|
148
|
-
|
|
148
|
+
/* Below the modal layer (--z-modal=400) so this floating control does not
|
|
149
|
+
paint over open dialogs; still above terminal content. */
|
|
150
|
+
z-index: var(--z-sticky);
|
|
149
151
|
display: none;
|
|
150
152
|
flex-direction: column;
|
|
151
153
|
gap: 10px;
|
|
@@ -264,7 +266,7 @@
|
|
|
264
266
|
|
|
265
267
|
@media (max-width: 820px) {
|
|
266
268
|
.install-btn {
|
|
267
|
-
bottom: calc(52px + 20px +
|
|
269
|
+
bottom: calc(52px + 20px + var(--sa-bottom));
|
|
268
270
|
}
|
|
269
271
|
}
|
|
270
272
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* safe-area.css — PWA standalone safe-area insets for fixed/absolute overlays.
|
|
2
|
+
|
|
3
|
+
The Dynamic Island fix in mobile.css covers the in-flow surfaces (tab bar,
|
|
4
|
+
file-browser header, mobile menu). Out-of-flow overlays (modals, toasts,
|
|
5
|
+
banners, bottom bars) are anchored to physical screen edges and inherit none
|
|
6
|
+
of that compensation, so they collide with the iOS Dynamic Island (top) and
|
|
7
|
+
home indicator (bottom) when launched as an installed PWA. This file applies
|
|
8
|
+
the insets to those surfaces, in ONE place.
|
|
9
|
+
|
|
10
|
+
Everything is gated behind `html.pwa-standalone` (set by the early-paint
|
|
11
|
+
script + matchMedia listener in app.js) so normal browser-tab and
|
|
12
|
+
desktop-non-PWA layouts are completely unaffected. Tokens --sa-top/--sa-bottom
|
|
13
|
+
come from tokens.css and are populated by app.js's _polyfillSafeAreaInsets()
|
|
14
|
+
(which works around the WebKit env()===0 bug), falling back to env() then 0.
|
|
15
|
+
|
|
16
|
+
Loaded LAST so these rules win regardless of source order. */
|
|
17
|
+
|
|
18
|
+
/* --- Full-screen modal overlays ---------------------------------------------
|
|
19
|
+
Pad the overlay itself so the flex-centered .modal-content is inset out of
|
|
20
|
+
both the island and the home indicator. box-sizing keeps the overlay
|
|
21
|
+
full-viewport while the padding shrinks the centering box. */
|
|
22
|
+
html.pwa-standalone .settings-modal,
|
|
23
|
+
html.pwa-standalone .session-modal,
|
|
24
|
+
html.pwa-standalone .plan-modal,
|
|
25
|
+
html.pwa-standalone .folder-browser-modal,
|
|
26
|
+
html.pwa-standalone .shortcuts-modal,
|
|
27
|
+
html.pwa-standalone .image-preview-modal,
|
|
28
|
+
html.pwa-standalone .terminal-overlay {
|
|
29
|
+
box-sizing: border-box;
|
|
30
|
+
padding-top: var(--sa-top);
|
|
31
|
+
padding-bottom: var(--sa-bottom);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Clamp modal content to the padded area so a tall modal (e.g. Settings)
|
|
35
|
+
can't overflow back up into the island. dvh tracks the live viewport. */
|
|
36
|
+
html.pwa-standalone .modal-content {
|
|
37
|
+
max-height: calc(100dvh - var(--sa-top) - var(--sa-bottom) - 40px);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* Full-height slide-in side panel (file browser). Its header already gets the
|
|
41
|
+
top inset via mobile.css; pad the bottom so the file list clears the home
|
|
42
|
+
indicator. box-sizing keeps the panel viewport-height. */
|
|
43
|
+
html.pwa-standalone .file-browser-panel {
|
|
44
|
+
box-sizing: border-box;
|
|
45
|
+
padding-bottom: var(--sa-bottom);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* --- Top-anchored transient surfaces ---------------------------------------- */
|
|
49
|
+
html.pwa-standalone .toast-container {
|
|
50
|
+
top: calc(52px + var(--sa-top));
|
|
51
|
+
}
|
|
52
|
+
html.pwa-standalone .banner-base {
|
|
53
|
+
padding-top: calc(8px + var(--sa-top));
|
|
54
|
+
}
|
|
55
|
+
html.pwa-standalone .notif-permission-prompt {
|
|
56
|
+
top: calc(60px + var(--sa-top));
|
|
57
|
+
}
|
|
58
|
+
html.pwa-standalone .fb-find-panel {
|
|
59
|
+
top: calc(80px + var(--sa-top));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* --- Bottom-anchored bars: lift above the home indicator -------------------- */
|
|
63
|
+
html.pwa-standalone .extra-keys-bar {
|
|
64
|
+
bottom: var(--sa-bottom);
|
|
65
|
+
}
|
|
66
|
+
html.pwa-standalone .input-overlay {
|
|
67
|
+
bottom: calc(16px + var(--sa-bottom));
|
|
68
|
+
}
|
package/src/public/extra-keys.js
CHANGED
|
@@ -28,11 +28,11 @@ class ExtraKeys {
|
|
|
28
28
|
{ label: 'End', data: '\x1b[F' },
|
|
29
29
|
{ label: 'PgUp', data: '\x1b[5~' },
|
|
30
30
|
{ label: 'PgDn', data: '\x1b[6~' },
|
|
31
|
-
{ label: '\u2190', data: '\x1b[D', aria: 'Left arrow' },
|
|
32
|
-
{ label: '\u2192', data: '\x1b[C', aria: 'Right arrow' },
|
|
33
|
-
{ label: '\u2191', data: '\x1b[A', aria: 'Up arrow' },
|
|
34
|
-
{ label: '\u2193', data: '\x1b[B', aria: 'Down arrow' },
|
|
35
|
-
{ label: '\u21E9', dismiss: true, aria: 'Dismiss keyboard' },
|
|
31
|
+
{ label: '\u2190', data: '\x1b[D', aria: 'Left arrow', title: 'Left arrow' },
|
|
32
|
+
{ label: '\u2192', data: '\x1b[C', aria: 'Right arrow', title: 'Right arrow' },
|
|
33
|
+
{ label: '\u2191', data: '\x1b[A', aria: 'Up arrow', title: 'Up arrow' },
|
|
34
|
+
{ label: '\u2193', data: '\x1b[B', aria: 'Down arrow', title: 'Down arrow' },
|
|
35
|
+
{ label: '\u21E9', dismiss: true, aria: 'Dismiss keyboard', title: 'Dismiss keyboard' },
|
|
36
36
|
];
|
|
37
37
|
|
|
38
38
|
const row2Keys = [
|
|
@@ -86,13 +86,13 @@ class ExtraKeys {
|
|
|
86
86
|
btn.className = 'extra-key';
|
|
87
87
|
btn.textContent = key.label;
|
|
88
88
|
btn.setAttribute('aria-label', key.aria || key.label);
|
|
89
|
+
if (key.title) btn.setAttribute('title', key.title);
|
|
89
90
|
|
|
90
91
|
if (key.dismiss) {
|
|
91
92
|
btn.classList.add('extra-key-dismiss');
|
|
92
93
|
btn.addEventListener('click', () => this._dismiss());
|
|
93
94
|
} else if (key.handler) {
|
|
94
95
|
btn.classList.add('extra-key-clipboard');
|
|
95
|
-
if (key.title) btn.setAttribute('title', key.title);
|
|
96
96
|
btn.addEventListener('click', () => {
|
|
97
97
|
if (key.handler === 'copy') {
|
|
98
98
|
this._handleCopy();
|
|
@@ -338,6 +338,17 @@
|
|
|
338
338
|
// callback is also tolerated (defensive coding per agent-instructions/05).
|
|
339
339
|
this.getCwd = typeof options.getCwd === 'function' ? options.getCwd : null;
|
|
340
340
|
|
|
341
|
+
// Optional callback returning the active session's id. Sent as the
|
|
342
|
+
// `session` query param on /api/files so the SERVER can resolve the
|
|
343
|
+
// default root (session.liveCwd || session.workingDir) even when the
|
|
344
|
+
// client's cwd cache is cold (e.g. right after a page reload). Tolerant
|
|
345
|
+
// of falsy/throwing callbacks like getCwd.
|
|
346
|
+
this.getSessionId = typeof options.getSessionId === 'function' ? options.getSessionId : null;
|
|
347
|
+
// Server-reported "home" for the active session (its working dir). Set
|
|
348
|
+
// from each /api/files response; navigateHome() roots here so "Home"
|
|
349
|
+
// means the tab's session dir, not the server's global baseFolder.
|
|
350
|
+
this._homePath = null;
|
|
351
|
+
|
|
341
352
|
this._open = false;
|
|
342
353
|
this._currentPath = null;
|
|
343
354
|
this._basePath = null;
|
|
@@ -858,9 +869,23 @@
|
|
|
858
869
|
var self = this;
|
|
859
870
|
var params = new URLSearchParams();
|
|
860
871
|
if (dirPath) params.append('path', dirPath);
|
|
872
|
+
// Always forward the active session id (when known). The server uses it
|
|
873
|
+
// ONLY to pick the default root when no `path` is given, and to report
|
|
874
|
+
// `home`; it is ignored when `path` is present, so explicit navigation
|
|
875
|
+
// (breadcrumbs / up / folder clicks) is unaffected.
|
|
876
|
+
var sid = null;
|
|
877
|
+
if (this.getSessionId) { try { sid = this.getSessionId(); } catch (_) { sid = null; } }
|
|
878
|
+
if (sid) params.append('session', sid);
|
|
879
|
+
this._lastRenderedSession = sid;
|
|
861
880
|
params.append('limit', '500');
|
|
862
881
|
params.append('offset', '0');
|
|
863
882
|
|
|
883
|
+
// Monotonic request token: tab switches / rapid folder clicks can leave
|
|
884
|
+
// several /api/files fetches in flight at once. Only the LATEST request is
|
|
885
|
+
// allowed to commit its response, so a slow earlier fetch can't overwrite
|
|
886
|
+
// the UI (or _homePath / the watcher root) with stale data.
|
|
887
|
+
var reqId = (this._navSeq = (this._navSeq || 0) + 1);
|
|
888
|
+
|
|
864
889
|
this._statusBar.textContent = 'Loading...';
|
|
865
890
|
|
|
866
891
|
this.authFetch('/api/files?' + params.toString())
|
|
@@ -869,14 +894,28 @@
|
|
|
869
894
|
return resp.json();
|
|
870
895
|
})
|
|
871
896
|
.then(function (data) {
|
|
897
|
+
if (reqId !== self._navSeq) return; // superseded by a newer navigateTo
|
|
872
898
|
self._currentPath = data.currentPath;
|
|
873
899
|
self._basePath = data.baseFolder;
|
|
900
|
+
// `home` is the session's working dir (server-resolved); navigateHome
|
|
901
|
+
// roots here. Falls back to baseFolder for sessionless/legacy responses.
|
|
902
|
+
self._homePath = data.home || data.baseFolder;
|
|
874
903
|
self._items = data.items;
|
|
875
904
|
self._renderBreadcrumbs();
|
|
876
905
|
self._renderItems();
|
|
877
906
|
self._showBrowseView();
|
|
878
907
|
self._statusBar.textContent = data.totalCount + ' item' + (data.totalCount !== 1 ? 's' : '');
|
|
879
908
|
|
|
909
|
+
// fs-watcher: (re)connect to the dir the server actually resolved.
|
|
910
|
+
// open() only connects when it knows the path client-side; on a cold
|
|
911
|
+
// cache it passes null and the server resolves the session root here,
|
|
912
|
+
// so connect against data.currentPath to cover that case (and re-root
|
|
913
|
+
// when a tab switch re-navigates). connect() is idempotent per path.
|
|
914
|
+
var w = self._ensureFileWatcher();
|
|
915
|
+
if (w && typeof w.connect === 'function' && data.currentPath) {
|
|
916
|
+
try { w.connect(data.currentPath); } catch (_) { /* swallow */ }
|
|
917
|
+
}
|
|
918
|
+
|
|
880
919
|
// fs-watcher (#41 / ADR-0017 — wire model post-ff79038): one
|
|
881
920
|
// EventSource per session at panel mount, refcount-based per-path
|
|
882
921
|
// subscriptions multiplexed over it. Listing direct-child paths
|
|
@@ -900,6 +939,7 @@
|
|
|
900
939
|
}
|
|
901
940
|
})
|
|
902
941
|
.catch(function (err) {
|
|
942
|
+
if (reqId !== self._navSeq) return; // superseded; don't clobber newer status
|
|
903
943
|
self._statusBar.textContent = 'Error: ' + err.message;
|
|
904
944
|
});
|
|
905
945
|
};
|
|
@@ -916,7 +956,9 @@
|
|
|
916
956
|
|
|
917
957
|
FileBrowserPanel.prototype.navigateHome = function () {
|
|
918
958
|
this._markManualNav();
|
|
919
|
-
|
|
959
|
+
// "Home" is the active session's working dir (server-reported `home`),
|
|
960
|
+
// falling back to the sandbox base if we haven't loaded a listing yet.
|
|
961
|
+
this.navigateTo(this._homePath || this._basePath);
|
|
920
962
|
};
|
|
921
963
|
|
|
922
964
|
// ---------------------------------------------------------------------------
|
|
@@ -945,6 +987,22 @@
|
|
|
945
987
|
return this._activeSessionId() === sessionId;
|
|
946
988
|
};
|
|
947
989
|
|
|
990
|
+
/**
|
|
991
|
+
* Entry point for app.js when the active tab/session changes. The panel is
|
|
992
|
+
* a singleton shared across tabs, so a tab switch must re-root it to the
|
|
993
|
+
* new session's working dir — open() short-circuits when already open and
|
|
994
|
+
* would otherwise keep showing the previous tab's directory. Re-navigates
|
|
995
|
+
* with NO explicit path so the server resolves the new session's root from
|
|
996
|
+
* the `?session` param (getSessionId() now returns the new id). No-op when
|
|
997
|
+
* the panel is closed or the session is unchanged. _markManualNav() is NOT
|
|
998
|
+
* called: a tab switch should re-root and resume following the new tab.
|
|
999
|
+
*/
|
|
1000
|
+
FileBrowserPanel.prototype.notifyActiveSessionChanged = function (sessionId) {
|
|
1001
|
+
if (!this._open) return;
|
|
1002
|
+
if (!sessionId || sessionId === this._lastRenderedSession) return;
|
|
1003
|
+
this.navigateTo(null);
|
|
1004
|
+
};
|
|
1005
|
+
|
|
948
1006
|
/**
|
|
949
1007
|
* Returns true when the panel is currently configured to follow the
|
|
950
1008
|
* given session's OSC-7 CWD. Defaults to true on first lookup, and
|
|
@@ -365,15 +365,50 @@
|
|
|
365
365
|
return {
|
|
366
366
|
cancelInFlight: cancelInFlight,
|
|
367
367
|
destroy: destroy,
|
|
368
|
+
// Public entry point so non-drop surfaces (attach button, paste) can
|
|
369
|
+
// route a FileList/array of File objects through the EXACT same pipeline:
|
|
370
|
+
// image/* → onImageDrop, everything else → upload + @path inject, with the
|
|
371
|
+
// same MAX_FILES_PER_DROP cap and bounded-parallel queue.
|
|
372
|
+
dispatchFiles: dispatchDrop,
|
|
368
373
|
};
|
|
369
374
|
}
|
|
370
375
|
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// triggerFilePicker — open a hidden <input type="file"> with NO accept filter
|
|
378
|
+
// (any file type), used by the attach button / context menu. Mirrors
|
|
379
|
+
// image-handler.js's picker minus the image-only constraint. Resolves the
|
|
380
|
+
// selected files via the onFiles callback. Browser-only.
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
function triggerFilePicker(onFiles, opts) {
|
|
383
|
+
if (typeof document === 'undefined') return;
|
|
384
|
+
opts = opts || {};
|
|
385
|
+
var input = document.createElement('input');
|
|
386
|
+
input.type = 'file';
|
|
387
|
+
if (opts.multiple) input.multiple = true;
|
|
388
|
+
input.style.display = 'none';
|
|
389
|
+
|
|
390
|
+
function cleanup() {
|
|
391
|
+
if (input.parentNode) input.parentNode.removeChild(input);
|
|
392
|
+
}
|
|
393
|
+
input.addEventListener('change', function () {
|
|
394
|
+
var files = input.files ? Array.prototype.slice.call(input.files) : [];
|
|
395
|
+
if (files.length && typeof onFiles === 'function') onFiles(files);
|
|
396
|
+
cleanup();
|
|
397
|
+
});
|
|
398
|
+
// Safety net: if the dialog is cancelled, no change fires — sweep the
|
|
399
|
+
// detached node shortly after to avoid leaking it.
|
|
400
|
+
document.body.appendChild(input);
|
|
401
|
+
input.click();
|
|
402
|
+
setTimeout(cleanup, 60000);
|
|
403
|
+
}
|
|
404
|
+
|
|
371
405
|
// ---------------------------------------------------------------------------
|
|
372
406
|
// Exports
|
|
373
407
|
// ---------------------------------------------------------------------------
|
|
374
408
|
|
|
375
409
|
var exportsObj = {
|
|
376
410
|
attachGenericDropHandler: attachGenericDropHandler,
|
|
411
|
+
triggerFilePicker: triggerFilePicker,
|
|
377
412
|
isImageMime: isImageMime,
|
|
378
413
|
sanitizeBasename: sanitizeBasename,
|
|
379
414
|
buildAtPathInjection: buildAtPathInjection,
|
|
@@ -115,6 +115,45 @@ function detectImageInClipboard(clipboardData) {
|
|
|
115
115
|
return null;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Collect NON-image File objects from a paste clipboard (files copied from a
|
|
120
|
+
* file manager surface as clipboardData.files / items with kind === 'file').
|
|
121
|
+
* Accepted image types are excluded — those go through the image preview flow.
|
|
122
|
+
* Used by onPaste's fall-through so non-image paste reaches the generic upload
|
|
123
|
+
* pipeline. Returns [] when there are no such files (normal text paste).
|
|
124
|
+
*
|
|
125
|
+
* @param {DataTransfer} clipboardData
|
|
126
|
+
* @returns {File[]}
|
|
127
|
+
*/
|
|
128
|
+
function collectNonImageFiles(clipboardData) {
|
|
129
|
+
var out = [];
|
|
130
|
+
if (!clipboardData) return out;
|
|
131
|
+
|
|
132
|
+
if (clipboardData.files && clipboardData.files.length > 0) {
|
|
133
|
+
for (var i = 0; i < clipboardData.files.length; i++) {
|
|
134
|
+
var f = clipboardData.files[i];
|
|
135
|
+
if (f && !isAcceptedImageType(f.type)) out.push(f);
|
|
136
|
+
}
|
|
137
|
+
// `files` is the authoritative FileList when present; `items` mirrors the
|
|
138
|
+
// same files (plus non-file entries), so scanning both would double-count.
|
|
139
|
+
// Fall back to `items` ONLY when `files` yielded nothing — same precedence
|
|
140
|
+
// as detectImageInClipboard above.
|
|
141
|
+
if (out.length) return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (clipboardData.items && clipboardData.items.length > 0) {
|
|
145
|
+
for (var j = 0; j < clipboardData.items.length; j++) {
|
|
146
|
+
var item = clipboardData.items[j];
|
|
147
|
+
if (item.kind === 'file' && !isAcceptedImageType(item.type)) {
|
|
148
|
+
var asFile = item.getAsFile();
|
|
149
|
+
if (asFile) out.push(asFile);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
118
157
|
// ---------------------------------------------------------------------------
|
|
119
158
|
// Blob → base64
|
|
120
159
|
// ---------------------------------------------------------------------------
|
|
@@ -504,6 +543,20 @@ function attachImageHandler(terminal, containerEl, options) {
|
|
|
504
543
|
e.preventDefault();
|
|
505
544
|
e.stopPropagation();
|
|
506
545
|
showImagePreview(image, options.onImageReady);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
// No image — if non-image files were pasted (e.g. copied from a file
|
|
549
|
+
// manager) and a handler is wired, route them to the generic pipeline.
|
|
550
|
+
// Purely additive: when there are no such files this is a no-op and the
|
|
551
|
+
// normal text paste proceeds untouched.
|
|
552
|
+
if (typeof options.onFilesPaste === 'function') {
|
|
553
|
+
var nonImageFiles = collectNonImageFiles(e.clipboardData);
|
|
554
|
+
if (nonImageFiles.length) {
|
|
555
|
+
e.preventDefault();
|
|
556
|
+
e.stopPropagation();
|
|
557
|
+
options.onFilesPaste(nonImageFiles);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
507
560
|
}
|
|
508
561
|
// No image found — let the normal text paste proceed
|
|
509
562
|
}
|
|
@@ -636,6 +689,7 @@ var imageHandlerExports = {
|
|
|
636
689
|
mimeToExtension: mimeToExtension,
|
|
637
690
|
formatFileSize: formatFileSize,
|
|
638
691
|
detectImageInClipboard: detectImageInClipboard,
|
|
692
|
+
collectNonImageFiles: collectNonImageFiles,
|
|
639
693
|
blobToBase64: blobToBase64,
|
|
640
694
|
showImagePreview: showImagePreview,
|
|
641
695
|
triggerFilePicker: triggerFilePicker,
|
package/src/public/index.html
CHANGED
|
@@ -42,6 +42,24 @@
|
|
|
42
42
|
} catch (_) { /* ignore */ }
|
|
43
43
|
})();
|
|
44
44
|
</script>
|
|
45
|
+
|
|
46
|
+
<!-- Detect PWA standalone mode before first paint so safe-area-top rules can apply
|
|
47
|
+
without a layout-shift flash. Class lives on <html> because <body> isn't parsed yet. -->
|
|
48
|
+
<script>
|
|
49
|
+
(function() {
|
|
50
|
+
try {
|
|
51
|
+
var mm = window.matchMedia;
|
|
52
|
+
var isStandalone = (mm && (
|
|
53
|
+
mm('(display-mode: standalone)').matches
|
|
54
|
+
|| mm('(display-mode: fullscreen)').matches
|
|
55
|
+
|| mm('(display-mode: minimal-ui)').matches
|
|
56
|
+
|| mm('(display-mode: window-controls-overlay)').matches
|
|
57
|
+
))
|
|
58
|
+
|| navigator.standalone === true;
|
|
59
|
+
if (isStandalone) document.documentElement.classList.add('pwa-standalone');
|
|
60
|
+
} catch (_) { /* ignore */ }
|
|
61
|
+
})();
|
|
62
|
+
</script>
|
|
45
63
|
<link rel="preload" href="fonts/MesloLGSNerdFont-Regular.woff2" as="font" type="font/woff2" crossorigin>
|
|
46
64
|
<link rel="preload" href="fonts/MesloLGSNerdFont-Bold.woff2" as="font" type="font/woff2" crossorigin>
|
|
47
65
|
<link rel="stylesheet" href="fonts.css">
|
|
@@ -68,6 +86,8 @@
|
|
|
68
86
|
<link rel="stylesheet" href="components/extra-keys.css">
|
|
69
87
|
<link rel="stylesheet" href="mobile.css">
|
|
70
88
|
<link rel="stylesheet" href="style.css">
|
|
89
|
+
<!-- Loaded last: PWA standalone safe-area overrides win over component CSS. -->
|
|
90
|
+
<link rel="stylesheet" href="components/safe-area.css">
|
|
71
91
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
72
92
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
73
93
|
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
|
@@ -143,7 +163,7 @@
|
|
|
143
163
|
<line x1="9" y1="14" x2="15" y2="14"/>
|
|
144
164
|
</svg>
|
|
145
165
|
</button>
|
|
146
|
-
<button class="tab-attach-image" id="attachImageBtn" title="Attach
|
|
166
|
+
<button class="tab-attach-image" id="attachImageBtn" title="Attach File" aria-label="Attach file">
|
|
147
167
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
148
168
|
stroke="currentColor" stroke-width="2">
|
|
149
169
|
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
|
|
@@ -292,7 +312,7 @@
|
|
|
292
312
|
<div class="ctx-sep" role="separator"></div>
|
|
293
313
|
<div class="ctx-item" data-action="attachImage" role="menuitem" tabindex="-1">
|
|
294
314
|
<span class="ctx-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></span>
|
|
295
|
-
<span>Attach
|
|
315
|
+
<span>Attach File...</span>
|
|
296
316
|
</div>
|
|
297
317
|
<div class="ctx-sep" role="separator"></div>
|
|
298
318
|
<div class="ctx-item" data-action="selectAll" role="menuitem" tabindex="-1">
|
package/src/public/mobile.css
CHANGED
|
@@ -2,17 +2,39 @@
|
|
|
2
2
|
Component-level responsive styles belong in their respective component CSS files.
|
|
3
3
|
This file contains ONLY cross-cutting mobile concerns that span multiple components. */
|
|
4
4
|
|
|
5
|
-
/* Safe area insets for notched devices (iPhone X+, etc.)
|
|
5
|
+
/* Safe area insets for notched devices (iPhone X+, etc.)
|
|
6
|
+
Top-axis padding is gated to PWA standalone mode (html.pwa-standalone) because
|
|
7
|
+
apple-mobile-web-app-status-bar-style="black-translucent" only causes the
|
|
8
|
+
Dynamic Island / status-bar collision when launched from the home screen.
|
|
9
|
+
In normal mobile-Safari/Edge tab mode the browser chrome already occupies that
|
|
10
|
+
space, so adding padding-top there would visibly push content down for no gain.
|
|
11
|
+
The CSS variable --safe-area-inset-top is populated by a JS polyfill in app.js
|
|
12
|
+
to work around the known WebKit bug where env(safe-area-inset-top) sometimes
|
|
13
|
+
returns 0px in iOS PWA standalone; env() is kept as the secondary fallback. */
|
|
6
14
|
@supports (padding: env(safe-area-inset-top)) {
|
|
7
15
|
.session-tabs-bar {
|
|
8
16
|
padding-left: max(10px, env(safe-area-inset-left));
|
|
9
17
|
padding-right: max(10px, env(safe-area-inset-right));
|
|
10
18
|
}
|
|
19
|
+
html.pwa-standalone .session-tabs-bar {
|
|
20
|
+
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top));
|
|
21
|
+
}
|
|
22
|
+
html.pwa-standalone .file-browser-panel .file-browser-header {
|
|
23
|
+
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top));
|
|
24
|
+
}
|
|
25
|
+
/* Baseline (any env()-capable browser) preserves the long-standing menu
|
|
26
|
+
inset; in tab mode env(safe-area-inset-top) is 0 so this is a no-op,
|
|
27
|
+
but it guards against any non-PWA notched case the standalone rule
|
|
28
|
+
below wouldn't cover. The standalone rule wins by specificity and
|
|
29
|
+
routes through the JS-populated --safe-area-inset-top variable. */
|
|
11
30
|
.mobile-menu {
|
|
12
31
|
padding-top: env(safe-area-inset-top);
|
|
13
32
|
}
|
|
33
|
+
html.pwa-standalone .mobile-menu {
|
|
34
|
+
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top));
|
|
35
|
+
}
|
|
14
36
|
.mode-switcher {
|
|
15
|
-
bottom: max(80px, calc(80px +
|
|
37
|
+
bottom: max(80px, calc(80px + var(--sa-bottom)));
|
|
16
38
|
right: max(20px, env(safe-area-inset-right));
|
|
17
39
|
}
|
|
18
40
|
}
|
|
@@ -183,12 +205,12 @@
|
|
|
183
205
|
|
|
184
206
|
/* Account for bottom nav height */
|
|
185
207
|
#app {
|
|
186
|
-
padding-bottom: calc(52px +
|
|
208
|
+
padding-bottom: calc(52px + var(--sa-bottom));
|
|
187
209
|
}
|
|
188
210
|
|
|
189
211
|
/* Move mode switcher above bottom nav */
|
|
190
212
|
.mode-switcher {
|
|
191
|
-
bottom: calc(62px +
|
|
213
|
+
bottom: calc(62px + var(--sa-bottom));
|
|
192
214
|
}
|
|
193
215
|
}
|
|
194
216
|
|
package/src/public/tokens.css
CHANGED
|
@@ -127,6 +127,17 @@
|
|
|
127
127
|
--z-input-overlay: 550;
|
|
128
128
|
--z-auth: 9999;
|
|
129
129
|
--z-max: 9999;
|
|
130
|
+
|
|
131
|
+
/* --- Safe-area insets (PWA standalone / notched devices) ---
|
|
132
|
+
Consumed by fixed/absolute overlays so they clear the iOS Dynamic Island
|
|
133
|
+
(top) and home indicator (bottom). The JS polyfill in app.js sets
|
|
134
|
+
--safe-area-inset-top/-bottom as VARIABLES to work around the WebKit bug
|
|
135
|
+
where env(safe-area-inset-*) returns 0px in iOS standalone; we fall back to
|
|
136
|
+
env() then 0. In normal (non-standalone) mode these resolve to 0, and the
|
|
137
|
+
consuming rules are gated behind html.pwa-standalone anyway, so there is no
|
|
138
|
+
effect on browser-tab or desktop-non-PWA layouts. */
|
|
139
|
+
--sa-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
|
|
140
|
+
--sa-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
|
|
130
141
|
}
|
|
131
142
|
|
|
132
143
|
/* ============================================================
|
package/src/server.js
CHANGED
|
@@ -870,7 +870,21 @@ class ClaudeCodeWebServer {
|
|
|
870
870
|
|
|
871
871
|
setupExpress() {
|
|
872
872
|
this.app.use(cors());
|
|
873
|
-
|
|
873
|
+
// Global JSON parser for normal endpoints (Express default ~100kb limit).
|
|
874
|
+
// The upload route mounts its own higher-limit parser (see
|
|
875
|
+
// POST /api/files/upload below); exempt it here so a base64 file body
|
|
876
|
+
// isn't rejected by the ~100kb default before the route runs. The
|
|
877
|
+
// trailing-slash normalization matches exactly the set of paths Express
|
|
878
|
+
// routes to that handler (`/api/files/upload` and `/api/files/upload/`).
|
|
879
|
+
const _globalJsonParser = express.json();
|
|
880
|
+
this.app.use((req, res, next) => {
|
|
881
|
+
// Case-insensitive + trailing-slash-normalized to match Express's
|
|
882
|
+
// default route matching (case-insensitive routing), so every form that
|
|
883
|
+
// reaches the upload handler is exempt from the ~100kb global parser.
|
|
884
|
+
const p = req.path.replace(/\/+$/, '').toLowerCase() || '/';
|
|
885
|
+
if (p === '/api/files/upload') return next();
|
|
886
|
+
return _globalJsonParser(req, res, next);
|
|
887
|
+
});
|
|
874
888
|
|
|
875
889
|
// Serve manifest.json with correct MIME type
|
|
876
890
|
this.app.get('/manifest.json', (req, res) => {
|
|
@@ -1403,7 +1417,30 @@ class ClaudeCodeWebServer {
|
|
|
1403
1417
|
|
|
1404
1418
|
// GET /api/files — List directory (files + folders), paginated
|
|
1405
1419
|
this.app.get('/api/files', (req, res) => {
|
|
1406
|
-
|
|
1420
|
+
// Per-tab file-browser root. Resolve the requesting session's home dir
|
|
1421
|
+
// (live OSC 7 cwd if tracked, else the spawn dir) when a `session` id is
|
|
1422
|
+
// supplied and its dir still validates. Used as (a) the default root
|
|
1423
|
+
// when the client sends no explicit `path`, and (b) the `home` value the
|
|
1424
|
+
// client points "Home" at — so Home stays the tab's dir even while
|
|
1425
|
+
// browsing subdirs. Mirrors GET /api/files/find. Falls back to baseFolder
|
|
1426
|
+
// for unknown/stale sessions so the browser never 403s on open.
|
|
1427
|
+
let sessionHome = null;
|
|
1428
|
+
const sid = typeof req.query.session === 'string' ? req.query.session : '';
|
|
1429
|
+
if (sid) {
|
|
1430
|
+
const session = this.claudeSessions.get(sid);
|
|
1431
|
+
if (session) {
|
|
1432
|
+
const candidate = session.liveCwd || session.workingDir;
|
|
1433
|
+
if (candidate && this.validatePath(candidate).valid) {
|
|
1434
|
+
sessionHome = candidate;
|
|
1435
|
+
} else {
|
|
1436
|
+
// Known session but its dir is missing or no longer inside the
|
|
1437
|
+
// sandbox — a real misconfiguration worth logging. (Unknown session
|
|
1438
|
+
// ids fall through silently: expected during cold-cache races.)
|
|
1439
|
+
console.warn(`/api/files: session ${sid} working dir unavailable; using baseFolder`);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
const requestedPath = req.query.path || sessionHome || this.baseFolder;
|
|
1407
1444
|
const validation = this.validatePath(requestedPath);
|
|
1408
1445
|
if (!validation.valid) {
|
|
1409
1446
|
return res.status(403).json({ error: validation.error });
|
|
@@ -1480,7 +1517,7 @@ class ClaudeCodeWebServer {
|
|
|
1480
1517
|
totalCount,
|
|
1481
1518
|
offset,
|
|
1482
1519
|
limit,
|
|
1483
|
-
home: normalizePath(this.baseFolder),
|
|
1520
|
+
home: normalizePath(sessionHome || this.baseFolder),
|
|
1484
1521
|
baseFolder: normalizePath(this.baseFolder),
|
|
1485
1522
|
});
|
|
1486
1523
|
} catch (error) {
|
|
@@ -2739,7 +2776,12 @@ class ClaudeCodeWebServer {
|
|
|
2739
2776
|
// per spec ("user shell config is sacrosanct" applies to .gitignore
|
|
2740
2777
|
// too — we don't want to silently introduce a new tracked-by-default
|
|
2741
2778
|
// side effect on the user's repo).
|
|
2742
|
-
|
|
2779
|
+
// Route parser limit is sized for base64 of the 10 MB decoded cap
|
|
2780
|
+
// (~14 MB) plus the small JSON envelope. The decoded-size guard below
|
|
2781
|
+
// (buffer.length > 10 MB) remains the real per-file cap. This parser is
|
|
2782
|
+
// the ONLY one that runs for this route — the global parser above skips
|
|
2783
|
+
// `/api/files/upload`, so this limit governs (not the ~100kb default).
|
|
2784
|
+
this.app.post('/api/files/upload', express.json({ limit: '20mb' }), async (req, res) => {
|
|
2743
2785
|
const { targetDir, fileName, content, overwrite } = req.body;
|
|
2744
2786
|
if (!targetDir || !fileName || !content) {
|
|
2745
2787
|
return res.status(400).json({ error: 'targetDir, fileName, and content are required' });
|
|
@@ -2932,6 +2974,24 @@ class ClaudeCodeWebServer {
|
|
|
2932
2974
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
|
2933
2975
|
}
|
|
2934
2976
|
});
|
|
2977
|
+
|
|
2978
|
+
// Body-parser error handler (4-arg, must be registered AFTER routes).
|
|
2979
|
+
// express.json() rejects oversized/malformed bodies via next(err) BEFORE
|
|
2980
|
+
// the route runs; without this, Express's default handler returns HTML,
|
|
2981
|
+
// which the JSON API clients can't parse. We key on err.type — the marker
|
|
2982
|
+
// body-parser stamps on its own errors — so unrelated next(err) calls are
|
|
2983
|
+
// left to the default handler.
|
|
2984
|
+
this.app.use((err, req, res, next) => {
|
|
2985
|
+
if (res.headersSent || !err || !err.type) return next(err);
|
|
2986
|
+
if (err.type === 'entity.too.large') {
|
|
2987
|
+
return res.status(413).json({ error: 'Request body too large' });
|
|
2988
|
+
}
|
|
2989
|
+
if (err.type === 'entity.parse.failed' || err.type === 'encoding.unsupported'
|
|
2990
|
+
|| err.type === 'charset.unsupported' || err.type === 'entity.verify.failed') {
|
|
2991
|
+
return res.status(err.status || err.statusCode || 400).json({ error: 'Invalid request body' });
|
|
2992
|
+
}
|
|
2993
|
+
return next(err);
|
|
2994
|
+
});
|
|
2935
2995
|
}
|
|
2936
2996
|
|
|
2937
2997
|
/**
|
package/src/utils/file-utils.js
CHANGED
|
@@ -132,19 +132,33 @@ function sanitizeFileName(name) {
|
|
|
132
132
|
throw new Error('File name is required');
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
// Strip path separators, null bytes, and control characters
|
|
135
|
+
// Strip path separators, null bytes, and control characters. Windows-first
|
|
136
|
+
// (primary deployment target): also strip the characters NTFS forbids in a
|
|
137
|
+
// file name — `< > : " | ? *`. Stripping ':' additionally neutralizes NTFS
|
|
138
|
+
// Alternate Data Streams (`name:stream`) and any stray drive-letter colon.
|
|
136
139
|
let sanitized = name
|
|
137
140
|
.replace(/[/\\]/g, '')
|
|
141
|
+
.replace(/[<>:"|?*]/g, '')
|
|
138
142
|
.replace(/\0/g, '')
|
|
139
143
|
.replace(/[\x01-\x1f\x7f]/g, '');
|
|
140
144
|
|
|
141
|
-
// Trim whitespace and dots from start/end
|
|
145
|
+
// Trim whitespace and dots from start/end (also covers Windows' rule that a
|
|
146
|
+
// file name may not end in a dot or space).
|
|
142
147
|
sanitized = sanitized.replace(/^[\s.]+|[\s.]+$/g, '');
|
|
143
148
|
|
|
144
149
|
if (!sanitized) {
|
|
145
150
|
throw new Error('File name is empty after sanitization');
|
|
146
151
|
}
|
|
147
152
|
|
|
153
|
+
// Windows reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) are
|
|
154
|
+
// illegal as a file's base name regardless of extension — `COM1.tar.gz` is
|
|
155
|
+
// still the COM1 device. Windows matches on the stem before the first dot,
|
|
156
|
+
// case-insensitively. Prefix with '_' to neutralize while staying readable.
|
|
157
|
+
const stem = sanitized.split('.')[0];
|
|
158
|
+
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(stem)) {
|
|
159
|
+
sanitized = '_' + sanitized;
|
|
160
|
+
}
|
|
161
|
+
|
|
148
162
|
if (sanitized.length > 255) {
|
|
149
163
|
const ext = path.extname(sanitized);
|
|
150
164
|
sanitized = sanitized.slice(0, 255 - ext.length) + ext;
|