@myrialabs/clopen 0.2.4 → 0.2.6
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/backend/chat/stream-manager.ts +136 -10
- package/backend/database/queries/session-queries.ts +9 -0
- package/backend/engine/adapters/claude/error-handler.ts +7 -2
- package/backend/engine/adapters/claude/stream.ts +21 -3
- package/backend/engine/adapters/opencode/message-converter.ts +37 -2
- package/backend/index.ts +25 -3
- package/backend/preview/browser/browser-preview-service.ts +16 -17
- package/backend/preview/browser/browser-video-capture.ts +199 -156
- package/backend/preview/browser/scripts/video-stream.ts +3 -5
- package/backend/snapshot/helpers.ts +15 -2
- package/backend/ws/snapshot/restore.ts +43 -2
- package/backend/ws/user/crud.ts +6 -3
- package/frontend/components/chat/input/ChatInput.svelte +6 -1
- package/frontend/components/chat/input/components/ChatInputActions.svelte +10 -0
- package/frontend/components/chat/message/MessageBubble.svelte +22 -1
- package/frontend/components/chat/tools/components/FileHeader.svelte +19 -5
- package/frontend/components/chat/tools/components/TerminalCommand.svelte +2 -2
- package/frontend/components/chat/widgets/FloatingTodoList.svelte +30 -26
- package/frontend/components/common/media/MediaPreview.svelte +187 -0
- package/frontend/components/files/FileViewer.svelte +23 -144
- package/frontend/components/git/DiffViewer.svelte +50 -130
- package/frontend/components/git/FileChangeItem.svelte +22 -0
- package/frontend/components/preview/browser/BrowserPreview.svelte +1 -0
- package/frontend/components/preview/browser/components/Canvas.svelte +110 -21
- package/frontend/components/preview/browser/components/Container.svelte +2 -1
- package/frontend/components/preview/browser/components/Toolbar.svelte +1 -8
- package/frontend/components/preview/browser/core/coordinator.svelte.ts +12 -4
- package/frontend/components/terminal/TerminalTabs.svelte +1 -2
- package/frontend/components/workspace/DesktopNavigator.svelte +27 -1
- package/frontend/components/workspace/WorkspaceLayout.svelte +3 -1
- package/frontend/components/workspace/panels/FilesPanel.svelte +77 -1
- package/frontend/components/workspace/panels/GitPanel.svelte +72 -28
- package/frontend/services/chat/chat.service.ts +6 -1
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +13 -5
- package/frontend/stores/core/files.svelte.ts +15 -1
- package/frontend/stores/ui/todo-panel.svelte.ts +39 -0
- package/frontend/utils/file-type.ts +68 -0
- package/index.html +1 -0
- package/package.json +1 -1
- package/shared/constants/binary-extensions.ts +40 -0
- package/shared/types/messaging/tool.ts +1 -0
- package/shared/utils/file-type-detection.ts +9 -1
- package/static/manifest.json +16 -0
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { settings } from '$frontend/stores/features/settings.svelte';
|
|
9
9
|
import ws from '$frontend/utils/ws';
|
|
10
10
|
import { getFileIcon } from '$frontend/utils/file-icon-mappings';
|
|
11
|
+
import { isPreviewableFile, isBinaryFile } from '$frontend/utils/file-type';
|
|
11
12
|
import { getGitStatusLabel, getGitStatusColor } from '$frontend/utils/git-status';
|
|
12
13
|
import type { IconName } from '$shared/types/ui/icons';
|
|
13
14
|
import type {
|
|
@@ -404,16 +405,8 @@
|
|
|
404
405
|
|
|
405
406
|
// Detect binary files by extension (for fallback when git diff returns empty)
|
|
406
407
|
function isBinaryByExtension(filePath: string): boolean {
|
|
407
|
-
const
|
|
408
|
-
return
|
|
409
|
-
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', '.svg',
|
|
410
|
-
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
|
411
|
-
'.zip', '.tar', '.gz', '.7z', '.rar', '.bz2',
|
|
412
|
-
'.exe', '.dll', '.so', '.dylib',
|
|
413
|
-
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
414
|
-
'.mp3', '.mp4', '.wav', '.avi', '.mkv', '.flv', '.mov', '.ogg',
|
|
415
|
-
'.sqlite', '.db',
|
|
416
|
-
].includes(ext);
|
|
408
|
+
const fileName = filePath.split(/[\\/]/).pop() || filePath;
|
|
409
|
+
return isPreviewableFile(fileName) || isBinaryFile(fileName);
|
|
417
410
|
}
|
|
418
411
|
|
|
419
412
|
async function viewDiff(file: GitFileChange, section: string) {
|
|
@@ -437,23 +430,74 @@
|
|
|
437
430
|
if (!isTwoColumnMode) viewMode = 'diff';
|
|
438
431
|
|
|
439
432
|
try {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
433
|
+
let diffResult: GitFileDiff | null = null;
|
|
434
|
+
|
|
435
|
+
if (status === '?') {
|
|
436
|
+
// Untracked files have no git diff — read file content to build a synthetic diff
|
|
437
|
+
const isBinary = isBinaryByExtension(file.path);
|
|
438
|
+
if (isBinary) {
|
|
439
|
+
diffResult = {
|
|
440
|
+
oldPath: file.path,
|
|
441
|
+
newPath: file.path,
|
|
442
|
+
status: '?',
|
|
443
|
+
hunks: [],
|
|
444
|
+
isBinary: true
|
|
445
|
+
};
|
|
446
|
+
} else {
|
|
447
|
+
const basePath = projectState.currentProject?.path || '';
|
|
448
|
+
const separator = basePath.includes('\\') ? '\\' : '/';
|
|
449
|
+
const fullPath = `${basePath}${separator}${file.path}`;
|
|
450
|
+
const fileData = await ws.http('files:read-file', { file_path: fullPath });
|
|
451
|
+
|
|
452
|
+
if (fileData.isBinary) {
|
|
453
|
+
// Backend detected binary content — show preview instead of diff
|
|
454
|
+
diffResult = {
|
|
455
|
+
oldPath: file.path,
|
|
456
|
+
newPath: file.path,
|
|
457
|
+
status: '?',
|
|
458
|
+
hunks: [],
|
|
459
|
+
isBinary: true
|
|
460
|
+
};
|
|
461
|
+
} else {
|
|
462
|
+
const lines = (fileData.content || '').split('\n');
|
|
463
|
+
diffResult = {
|
|
464
|
+
oldPath: file.path,
|
|
465
|
+
newPath: file.path,
|
|
466
|
+
status: '?',
|
|
467
|
+
hunks: [{
|
|
468
|
+
oldStart: 0,
|
|
469
|
+
oldLines: 0,
|
|
470
|
+
newStart: 1,
|
|
471
|
+
newLines: lines.length,
|
|
472
|
+
header: `@@ -0,0 +1,${lines.length} @@`,
|
|
473
|
+
lines: lines.map((line, i) => ({
|
|
474
|
+
type: 'add' as const,
|
|
475
|
+
content: line,
|
|
476
|
+
newLineNumber: i + 1
|
|
477
|
+
}))
|
|
478
|
+
}],
|
|
479
|
+
isBinary: false
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
} else {
|
|
484
|
+
const action = section === 'staged' ? 'git:diff-staged' : 'git:diff-unstaged';
|
|
485
|
+
const diffs = await ws.http(action, { projectId, filePath: file.path });
|
|
486
|
+
diffResult = diffs.length > 0 ? diffs[0] : null;
|
|
487
|
+
|
|
488
|
+
if (!diffResult) {
|
|
489
|
+
diffResult = {
|
|
490
|
+
oldPath: file.path,
|
|
491
|
+
newPath: file.path,
|
|
492
|
+
status: status || '?',
|
|
493
|
+
hunks: [],
|
|
494
|
+
isBinary: isBinaryByExtension(file.path)
|
|
495
|
+
};
|
|
496
|
+
} else if (status) {
|
|
497
|
+
// Override diff parser status with authoritative status from git status
|
|
498
|
+
// parseDiff defaults to 'M', but the real status (A, D, R, etc.) comes from git status
|
|
499
|
+
diffResult = { ...diffResult, status };
|
|
500
|
+
}
|
|
457
501
|
}
|
|
458
502
|
|
|
459
503
|
openTabs = openTabs.map(t =>
|
|
@@ -981,7 +1025,7 @@
|
|
|
981
1025
|
}
|
|
982
1026
|
|
|
983
1027
|
// Refresh the active diff tab if currently viewing one
|
|
984
|
-
if (activeTab && !activeTab.isLoading && activeTab.section !== 'commit') {
|
|
1028
|
+
if (activeTab && !activeTab.isLoading && activeTab.section !== 'commit' && activeTab.status !== '?') {
|
|
985
1029
|
const tab = activeTab;
|
|
986
1030
|
try {
|
|
987
1031
|
const action = tab.section === 'staged' ? 'git:diff-staged' : 'git:diff-unstaged';
|
|
@@ -196,7 +196,12 @@ class ChatService {
|
|
|
196
196
|
this.streamCompleted = true;
|
|
197
197
|
this.reconnected = false;
|
|
198
198
|
this.activeProcessId = null;
|
|
199
|
-
|
|
199
|
+
// Don't clear isCancelling here — it causes a race with presence.
|
|
200
|
+
// The chat:cancelled WS event arrives before broadcastPresence() updates,
|
|
201
|
+
// so clearing isCancelling lets the presence $effect re-enable isLoading
|
|
202
|
+
// (because hasActiveForSession is still true). The presence $effect will
|
|
203
|
+
// clear isCancelling once the stream is actually gone from presence.
|
|
204
|
+
this.setProcessState({ isLoading: false, isWaitingInput: false });
|
|
200
205
|
|
|
201
206
|
// Mark any tool_use blocks that never got a tool_result
|
|
202
207
|
this.markInterruptedTools();
|
|
@@ -115,17 +115,16 @@ export class BrowserWebCodecsService {
|
|
|
115
115
|
private lastAudioBytesReceived = 0;
|
|
116
116
|
private lastStatsTime = 0;
|
|
117
117
|
|
|
118
|
-
// ICE servers
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
{ urls: 'stun:stun1.l.google.com:19302' }
|
|
122
|
-
];
|
|
118
|
+
// ICE servers - empty for local connections (both peers on same machine)
|
|
119
|
+
// STUN servers are unnecessary for localhost and add 100-500ms ICE gathering latency
|
|
120
|
+
private readonly iceServers: RTCIceServer[] = [];
|
|
123
121
|
|
|
124
122
|
// Callbacks
|
|
125
123
|
private onConnectionChange: ((connected: boolean) => void) | null = null;
|
|
126
124
|
private onConnectionFailed: (() => void) | null = null;
|
|
127
125
|
private onNavigationReconnect: (() => void) | null = null; // Fast reconnection after navigation
|
|
128
126
|
private onReconnectingStart: (() => void) | null = null; // Signals reconnecting state started (for UI)
|
|
127
|
+
private onFirstFrame: (() => void) | null = null; // Fires immediately when first frame is decoded
|
|
129
128
|
private onError: ((error: Error) => void) | null = null;
|
|
130
129
|
private onStats: ((stats: BrowserWebCodecsStreamStats) => void) | null = null;
|
|
131
130
|
private onCursorChange: ((cursor: string) => void) | null = null;
|
|
@@ -641,6 +640,11 @@ export class BrowserWebCodecsService {
|
|
|
641
640
|
this.firstFrameTimestamp = frame.timestamp;
|
|
642
641
|
debug.log('webcodecs', 'First video frame rendered');
|
|
643
642
|
|
|
643
|
+
// Notify immediately so UI can hide loading overlay without polling delay
|
|
644
|
+
if (this.onFirstFrame) {
|
|
645
|
+
this.onFirstFrame();
|
|
646
|
+
}
|
|
647
|
+
|
|
644
648
|
// Reset navigation state - frames are flowing, navigation is complete
|
|
645
649
|
if (this.isNavigating) {
|
|
646
650
|
debug.log('webcodecs', 'Navigation complete - frames received, resetting navigation state');
|
|
@@ -1477,6 +1481,10 @@ export class BrowserWebCodecsService {
|
|
|
1477
1481
|
this.onReconnectingStart = handler;
|
|
1478
1482
|
}
|
|
1479
1483
|
|
|
1484
|
+
setFirstFrameHandler(handler: () => void): void {
|
|
1485
|
+
this.onFirstFrame = handler;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1480
1488
|
setErrorHandler(handler: (error: Error) => void): void {
|
|
1481
1489
|
this.onError = handler;
|
|
1482
1490
|
}
|
|
@@ -11,6 +11,7 @@ interface FileState {
|
|
|
11
11
|
expandedFolders: Set<string>;
|
|
12
12
|
isLoading: boolean;
|
|
13
13
|
error: string | null;
|
|
14
|
+
revealRequest: string | null;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
// File state using Svelte 5 runes
|
|
@@ -19,7 +20,8 @@ export const fileState = $state<FileState>({
|
|
|
19
20
|
selectedFile: null,
|
|
20
21
|
expandedFolders: new Set<string>(),
|
|
21
22
|
isLoading: false,
|
|
22
|
-
error: null
|
|
23
|
+
error: null,
|
|
24
|
+
revealRequest: null
|
|
23
25
|
});
|
|
24
26
|
|
|
25
27
|
// ========================================
|
|
@@ -70,4 +72,16 @@ export function setError(error: string | null) {
|
|
|
70
72
|
|
|
71
73
|
export function clearError() {
|
|
72
74
|
fileState.error = null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ========================================
|
|
78
|
+
// FILE REVEAL
|
|
79
|
+
// ========================================
|
|
80
|
+
|
|
81
|
+
export function requestRevealFile(filePath: string) {
|
|
82
|
+
fileState.revealRequest = filePath;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function clearRevealRequest() {
|
|
86
|
+
fileState.revealRequest = null;
|
|
73
87
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Todo Panel UI State
|
|
3
|
+
*
|
|
4
|
+
* Persists floating todo panel position and view mode via server-side user state.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import ws from '$frontend/utils/ws';
|
|
8
|
+
import { debug } from '$shared/utils/logger';
|
|
9
|
+
|
|
10
|
+
export interface TodoPanelState {
|
|
11
|
+
posY: number;
|
|
12
|
+
snapSide: 'left' | 'right';
|
|
13
|
+
isExpanded: boolean;
|
|
14
|
+
isMinimized: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const defaults: TodoPanelState = {
|
|
18
|
+
posY: 80,
|
|
19
|
+
snapSide: 'right',
|
|
20
|
+
isExpanded: true,
|
|
21
|
+
isMinimized: false,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const todoPanelState = $state<TodoPanelState>({ ...defaults });
|
|
25
|
+
|
|
26
|
+
/** Apply server-restored state during initialization. */
|
|
27
|
+
export function applyTodoPanelState(saved: Partial<TodoPanelState> | null): void {
|
|
28
|
+
if (saved && typeof saved === 'object') {
|
|
29
|
+
Object.assign(todoPanelState, { ...defaults, ...saved });
|
|
30
|
+
debug.log('workspace', 'Applied server todo panel state');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Persist current state to server (fire-and-forget). */
|
|
35
|
+
export function saveTodoPanelState(): void {
|
|
36
|
+
ws.http('user:save-state', { key: 'todoPanelState', value: { ...todoPanelState } }).catch(err => {
|
|
37
|
+
debug.error('workspace', 'Failed to save todo panel state:', err);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared file type detection utilities.
|
|
3
|
+
* Used by FileViewer, DiffViewer, and MediaPreview.
|
|
4
|
+
*
|
|
5
|
+
* Extension lists are sourced from shared/constants/binary-extensions.ts
|
|
6
|
+
* to stay in sync with the backend's file-type-detection.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { NON_PREVIEWABLE_BINARY_EXTENSIONS } from '$shared/constants/binary-extensions';
|
|
10
|
+
|
|
11
|
+
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp'];
|
|
12
|
+
const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.flac', '.aac', '.m4a', '.wma', '.opus'];
|
|
13
|
+
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogv', '.mov', '.avi', '.mkv', '.flv', '.wmv', '.m4v'];
|
|
14
|
+
|
|
15
|
+
function getExtension(fileName: string): string {
|
|
16
|
+
return fileName.substring(fileName.lastIndexOf('.')).toLowerCase();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function isImageFile(fileName: string): boolean {
|
|
20
|
+
return IMAGE_EXTENSIONS.includes(getExtension(fileName));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function isSvgFile(fileName: string): boolean {
|
|
24
|
+
return fileName.toLowerCase().endsWith('.svg');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isPdfFile(fileName: string): boolean {
|
|
28
|
+
return fileName.toLowerCase().endsWith('.pdf');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isAudioFile(fileName: string): boolean {
|
|
32
|
+
return AUDIO_EXTENSIONS.includes(getExtension(fileName));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isVideoFile(fileName: string): boolean {
|
|
36
|
+
return VIDEO_EXTENSIONS.includes(getExtension(fileName));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Non-previewable binary file detection, sourced from shared constants. */
|
|
40
|
+
export function isBinaryFile(fileName: string): boolean {
|
|
41
|
+
return NON_PREVIEWABLE_BINARY_EXTENSIONS.has(getExtension(fileName));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isVisualFile(fileName: string): boolean {
|
|
45
|
+
return isImageFile(fileName) || isSvgFile(fileName) || isPdfFile(fileName);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isMediaFile(fileName: string): boolean {
|
|
49
|
+
return isAudioFile(fileName) || isVideoFile(fileName);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Returns true if the file can be rendered as a media preview (image, SVG, PDF, audio, video). */
|
|
53
|
+
export function isPreviewableFile(fileName: string): boolean {
|
|
54
|
+
return isVisualFile(fileName) || isMediaFile(fileName);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Detects the backend's binary placeholder content pattern. */
|
|
58
|
+
export function isBinaryContent(content: string): boolean {
|
|
59
|
+
return /^\[Binary file - \d+ bytes\]$/.test(content);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function formatFileSize(bytes: number): string {
|
|
63
|
+
if (bytes === 0) return '0 B';
|
|
64
|
+
const k = 1024;
|
|
65
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
66
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
67
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
68
|
+
}
|
package/index.html
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myrialabs/clopen",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "All-in-one web workspace for Claude Code & OpenCode — chat, terminal, git, browser preview, checkpoints, and real-time collaboration",
|
|
5
5
|
"author": "Myria Labs",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared binary file extension constants.
|
|
3
|
+
* Single source of truth used by both backend (file-type-detection) and frontend (file-type).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Binary extensions that CAN be previewed in the browser (image, audio, video, PDF) */
|
|
7
|
+
export const PREVIEWABLE_BINARY_EXTENSIONS = new Set([
|
|
8
|
+
// Images
|
|
9
|
+
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.tiff', '.tif',
|
|
10
|
+
// Audio
|
|
11
|
+
'.mp3', '.wav', '.ogg', '.flac', '.aac', '.m4a', '.wma', '.opus',
|
|
12
|
+
// Video
|
|
13
|
+
'.mp4', '.webm', '.avi', '.mkv', '.mov', '.flv', '.wmv', '.m4v', '.ogv',
|
|
14
|
+
// Documents
|
|
15
|
+
'.pdf',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
/** Binary extensions that CANNOT be previewed — archives, executables, fonts, databases, etc. */
|
|
19
|
+
export const NON_PREVIEWABLE_BINARY_EXTENSIONS = new Set([
|
|
20
|
+
// Archives
|
|
21
|
+
'.zip', '.tar', '.gz', '.bz2', '.xz', '.7z', '.rar', '.zst', '.lz4',
|
|
22
|
+
// Office documents
|
|
23
|
+
'.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
|
24
|
+
// Executables & compiled
|
|
25
|
+
'.exe', '.dll', '.com', '.bin', '.dat', '.pak', '.res',
|
|
26
|
+
'.beam', '.pyc', '.pyo', '.class', '.o', '.obj', '.so', '.dylib', '.a',
|
|
27
|
+
'.lib', '.wasm', '.bc', '.pdb', '.dSYM',
|
|
28
|
+
// Fonts
|
|
29
|
+
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
30
|
+
// Databases
|
|
31
|
+
'.sqlite', '.db', '.mdb',
|
|
32
|
+
// Disk images & other
|
|
33
|
+
'.iso', '.dmg', '.img', '.swf', '.swc',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** All known binary extensions (union of previewable + non-previewable) */
|
|
37
|
+
export const ALL_BINARY_EXTENSIONS = new Set([
|
|
38
|
+
...PREVIEWABLE_BINARY_EXTENSIONS,
|
|
39
|
+
...NON_PREVIEWABLE_BINARY_EXTENSIONS,
|
|
40
|
+
]);
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { fileTypeFromBuffer } from 'file-type';
|
|
2
2
|
import isTextPath from 'is-text-path';
|
|
3
3
|
|
|
4
|
+
import { ALL_BINARY_EXTENSIONS } from '$shared/constants/binary-extensions';
|
|
4
5
|
import { debug } from '$shared/utils/logger';
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Simple and reliable text file detection using external libraries
|
|
7
9
|
*/
|
|
@@ -11,7 +13,13 @@ export async function isTextFile(filePath: string): Promise<boolean> {
|
|
|
11
13
|
if (isTextPath(filePath)) {
|
|
12
14
|
return true;
|
|
13
15
|
}
|
|
14
|
-
|
|
16
|
+
|
|
17
|
+
// Fast reject: known binary extensions
|
|
18
|
+
const ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
|
|
19
|
+
if (ALL_BINARY_EXTENSIONS.has(ext)) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
const file = Bun.file(filePath);
|
|
16
24
|
const buffer = Buffer.from(await file.arrayBuffer());
|
|
17
25
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Clopen",
|
|
3
|
+
"short_name": "Clopen",
|
|
4
|
+
"description": "All-in-one web workspace for Claude Code & OpenCode — chat, terminal, git, browser preview, checkpoints, and real-time collaboration",
|
|
5
|
+
"start_url": "/",
|
|
6
|
+
"display": "standalone",
|
|
7
|
+
"background_color": "#0f172a",
|
|
8
|
+
"theme_color": "#0f172a",
|
|
9
|
+
"icons": [
|
|
10
|
+
{
|
|
11
|
+
"src": "/favicon.svg",
|
|
12
|
+
"sizes": "any",
|
|
13
|
+
"type": "image/svg+xml"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|