@myrialabs/clopen 0.2.4 → 0.2.5

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.
@@ -18,6 +18,7 @@
18
18
  import { showConfirm } from '$frontend/stores/ui/dialog.svelte';
19
19
  import { getFileIcon } from '$frontend/utils/file-icon-mappings';
20
20
  import type { IconName } from '$shared/types/ui/icons';
21
+ import { fileState, clearRevealRequest } from '$frontend/stores/core/files.svelte';
21
22
 
22
23
  // Props
23
24
  interface Props {
@@ -51,6 +52,7 @@
51
52
  savedContent: string;
52
53
  isLoading: boolean;
53
54
  externallyChanged?: boolean;
55
+ isBinary?: boolean;
54
56
  }
55
57
 
56
58
  let openTabs = $state<EditorTab[]>([]);
@@ -68,6 +70,7 @@
68
70
  let displayFile = $state<FileNode | null>(null);
69
71
  let displayLoading = $state(false);
70
72
  let displayTargetLine = $state<number | undefined>(undefined);
73
+ let displayIsBinary = $state(false);
71
74
  let displayExternallyChanged = $state(false);
72
75
 
73
76
  // Sync display state when active tab changes
@@ -78,12 +81,14 @@
78
81
  displaySavedContent = activeTab.savedContent;
79
82
  displayLoading = activeTab.isLoading;
80
83
  displayExternallyChanged = activeTab.externallyChanged || false;
84
+ displayIsBinary = activeTab.isBinary || false;
81
85
  } else {
82
86
  displayFile = null;
83
87
  displayContent = '';
84
88
  displaySavedContent = '';
85
89
  displayLoading = false;
86
90
  displayExternallyChanged = false;
91
+ displayIsBinary = false;
87
92
  }
88
93
  });
89
94
 
@@ -341,9 +346,10 @@
341
346
  try {
342
347
  const data = await ws.http('files:read-file', { file_path: filePath });
343
348
  const content = data.content || '';
349
+ const isBinary = data.isBinary || false;
344
350
  openTabs = openTabs.map(t =>
345
351
  t.file.path === filePath
346
- ? { ...t, currentContent: content, savedContent: content, isLoading: false }
352
+ ? { ...t, currentContent: content, savedContent: content, isLoading: false, isBinary }
347
353
  : t
348
354
  );
349
355
  // Update display if this is the active tab
@@ -351,6 +357,7 @@
351
357
  displayContent = content;
352
358
  displaySavedContent = content;
353
359
  displayLoading = false;
360
+ displayIsBinary = isBinary;
354
361
  }
355
362
  return true;
356
363
  } catch (err) {
@@ -377,6 +384,7 @@
377
384
  displayContent = tab.currentContent;
378
385
  displaySavedContent = tab.savedContent;
379
386
  displayLoading = tab.isLoading;
387
+ displayIsBinary = tab.isBinary || false;
380
388
  }
381
389
  }
382
390
 
@@ -1086,6 +1094,72 @@
1086
1094
  prevTwoColumnMode = isTwoColumnMode;
1087
1095
  });
1088
1096
 
1097
+ // Reveal and open file in editor when requested from external components (e.g. chat tools)
1098
+ $effect(() => {
1099
+ const revealPath = fileState.revealRequest;
1100
+ if (!revealPath || !projectPath) return;
1101
+
1102
+ clearRevealRequest();
1103
+
1104
+ // Expand all parent directories in the tree
1105
+ const relativePath = revealPath.startsWith(projectPath)
1106
+ ? revealPath.slice(projectPath.length).replace(/^[/\\]/, '')
1107
+ : '';
1108
+ if (relativePath) {
1109
+ const parts = relativePath.split(/[/\\]/);
1110
+ let currentPath = projectPath;
1111
+ for (let i = 0; i < parts.length - 1; i++) {
1112
+ currentPath += '/' + parts[i];
1113
+ expandedFolders.add(currentPath);
1114
+ }
1115
+ expandedFolders = new Set(expandedFolders);
1116
+ }
1117
+
1118
+ // Open file in editor tab (handleFileOpen also handles missing tree nodes)
1119
+ revealAndOpenFile(revealPath);
1120
+ });
1121
+
1122
+ async function revealAndOpenFile(filePath: string) {
1123
+ const existingTab = openTabs.find(t => t.file.path === filePath);
1124
+ if (existingTab) {
1125
+ // Tab already open — just activate it
1126
+ activeTabPath = filePath;
1127
+ if (!isTwoColumnMode) viewMode = 'viewer';
1128
+ scrollToActiveFile(filePath);
1129
+ return;
1130
+ }
1131
+
1132
+ // Create new tab
1133
+ let file = findFileInTree(projectFiles, filePath);
1134
+ if (!file) {
1135
+ const fileName = filePath.split(/[/\\]/).pop() || 'Untitled';
1136
+ file = { name: fileName, path: filePath, type: 'file', size: 0, modified: new Date() };
1137
+ }
1138
+ const newTab: EditorTab = {
1139
+ file,
1140
+ currentContent: '',
1141
+ savedContent: '',
1142
+ isLoading: true
1143
+ };
1144
+ openTabs = [...openTabs, newTab];
1145
+ activeTabPath = filePath;
1146
+ if (!isTwoColumnMode) viewMode = 'viewer';
1147
+
1148
+ // Load content and verify file exists on disk
1149
+ const success = await loadTabContent(filePath);
1150
+ if (!success) {
1151
+ openTabs = openTabs.filter(t => t.file.path !== filePath);
1152
+ if (activeTabPath === filePath) {
1153
+ activeTabPath = openTabs.length > 0 ? openTabs[openTabs.length - 1].file.path : null;
1154
+ if (!activeTabPath && !isTwoColumnMode) viewMode = 'tree';
1155
+ }
1156
+ showErrorAlert('File no longer exists on disk.', 'File Not Found');
1157
+ return;
1158
+ }
1159
+
1160
+ scrollToActiveFile(filePath);
1161
+ }
1162
+
1089
1163
  // Save state to persistent storage on component destruction (mobile/desktop switch)
1090
1164
  onDestroy(() => {
1091
1165
  if (projectPath) {
@@ -1269,6 +1343,7 @@
1269
1343
  onToggleWordWrap={() => { wordWrapEnabled = !wordWrapEnabled; }}
1270
1344
  externallyChanged={displayExternallyChanged}
1271
1345
  onForceReload={forceReloadTab}
1346
+ isBinary={displayIsBinary}
1272
1347
  />
1273
1348
  </div>
1274
1349
  </div>
@@ -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 ext = filePath.substring(filePath.lastIndexOf('.')).toLowerCase();
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
- const action = section === 'staged' ? 'git:diff-staged' : 'git:diff-unstaged';
441
- const diffs = await ws.http(action, { projectId, filePath: file.path });
442
- let diffResult: GitFileDiff | null = diffs.length > 0 ? diffs[0] : null;
443
-
444
- // When git diff returns empty (e.g. untracked files), create a synthetic diff
445
- if (!diffResult) {
446
- diffResult = {
447
- oldPath: file.path,
448
- newPath: file.path,
449
- status: status || '?',
450
- hunks: [],
451
- isBinary: isBinaryByExtension(file.path)
452
- };
453
- } else if (status) {
454
- // Override diff parser status with authoritative status from git status
455
- // parseDiff defaults to 'M', but the real status (A, D, R, etc.) comes from git status
456
- diffResult = { ...diffResult, status };
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';
@@ -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
@@ -3,6 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <link rel="icon" href="/favicon.svg" />
6
+ <link rel="manifest" href="/manifest.json" />
6
7
  <meta name="viewport" content="width=device-width, initial-scale=1" />
7
8
  <meta
8
9
  name="description"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myrialabs/clopen",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
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
+ ]);
@@ -108,6 +108,7 @@ export interface ToolResult {
108
108
  type: 'tool_result';
109
109
  tool_use_id: string;
110
110
  content: string;
111
+ is_error?: boolean;
111
112
  }
112
113
 
113
114
  // ============================================================
@@ -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
+ }