@myrialabs/clopen 0.2.3 → 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.
- package/backend/engine/adapters/claude/stream.ts +107 -0
- package/backend/engine/adapters/opencode/message-converter.ts +37 -2
- package/backend/engine/adapters/opencode/stream.ts +81 -1
- package/backend/engine/types.ts +17 -0
- package/backend/git/git-service.ts +2 -1
- package/backend/ws/git/commit-message.ts +108 -0
- package/backend/ws/git/index.ts +3 -1
- package/backend/ws/system/index.ts +7 -1
- package/backend/ws/system/operations.ts +28 -2
- package/backend/ws/user/crud.ts +6 -3
- package/frontend/App.svelte +3 -0
- package/frontend/components/auth/SetupPage.svelte +2 -2
- package/frontend/components/chat/input/ChatInput.svelte +1 -1
- package/frontend/components/chat/message/ChatMessage.svelte +64 -16
- package/frontend/components/chat/tools/components/FileHeader.svelte +19 -5
- package/frontend/components/chat/widgets/FloatingTodoList.svelte +30 -26
- package/frontend/components/checkpoint/ConflictResolutionModal.svelte +189 -0
- package/frontend/components/checkpoint/TimelineModal.svelte +7 -162
- package/frontend/components/common/feedback/RestartRequiredModal.svelte +53 -0
- package/frontend/components/common/feedback/UpdateBanner.svelte +17 -6
- package/frontend/components/common/media/MediaPreview.svelte +187 -0
- package/frontend/components/files/FileViewer.svelte +11 -143
- package/frontend/components/git/BranchManager.svelte +143 -155
- package/frontend/components/git/CommitForm.svelte +61 -11
- package/frontend/components/git/DiffViewer.svelte +50 -130
- package/frontend/components/git/FileChangeItem.svelte +22 -0
- package/frontend/components/settings/SettingsModal.svelte +1 -1
- package/frontend/components/settings/SettingsView.svelte +1 -1
- package/frontend/components/settings/engines/AIEnginesSettings.svelte +2 -2
- package/frontend/components/settings/general/UpdateSettings.svelte +10 -3
- package/frontend/components/settings/git/GitSettings.svelte +392 -0
- package/frontend/components/settings/model/EngineModelPicker.svelte +275 -0
- package/frontend/components/settings/model/ModelSettings.svelte +172 -289
- package/frontend/components/workspace/DesktopNavigator.svelte +27 -1
- package/frontend/components/workspace/PanelHeader.svelte +1 -3
- package/frontend/components/workspace/WorkspaceLayout.svelte +14 -2
- package/frontend/components/workspace/panels/FilesPanel.svelte +76 -1
- package/frontend/components/workspace/panels/GitPanel.svelte +84 -33
- package/frontend/main.ts +4 -0
- package/frontend/stores/core/files.svelte.ts +15 -1
- package/frontend/stores/features/settings.svelte.ts +13 -2
- package/frontend/stores/ui/settings-modal.svelte.ts +9 -9
- package/frontend/stores/ui/todo-panel.svelte.ts +39 -0
- package/frontend/stores/ui/update.svelte.ts +45 -4
- 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/git.ts +15 -0
- package/shared/types/messaging/tool.ts +1 -0
- package/shared/types/stores/settings.ts +12 -0
- package/shared/utils/file-type-detection.ts +9 -1
- package/static/manifest.json +16 -0
|
@@ -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
|
|
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 =>
|
|
@@ -674,7 +718,7 @@
|
|
|
674
718
|
isPulling = true;
|
|
675
719
|
try {
|
|
676
720
|
const prevBehind = branchInfo?.behind ?? 0;
|
|
677
|
-
const result = await ws.http('git:pull', { projectId, remote: selectedRemote });
|
|
721
|
+
const result = await ws.http('git:pull', { projectId, remote: selectedRemote, branch: branchInfo?.current });
|
|
678
722
|
if (!result.success) {
|
|
679
723
|
if (result.message.includes('conflict')) {
|
|
680
724
|
await loadAll();
|
|
@@ -704,7 +748,7 @@
|
|
|
704
748
|
isPushing = true;
|
|
705
749
|
try {
|
|
706
750
|
const prevAhead = branchInfo?.ahead ?? 0;
|
|
707
|
-
const result = await ws.http('git:push', { projectId, remote: selectedRemote });
|
|
751
|
+
const result = await ws.http('git:push', { projectId, remote: selectedRemote, branch: branchInfo?.current });
|
|
708
752
|
if (!result.success) {
|
|
709
753
|
showError('Push Failed', result.message);
|
|
710
754
|
} else {
|
|
@@ -971,11 +1015,17 @@
|
|
|
971
1015
|
if (changeDebounce) clearTimeout(changeDebounce);
|
|
972
1016
|
changeDebounce = setTimeout(async () => {
|
|
973
1017
|
changeDebounce = null;
|
|
974
|
-
// Refresh git status
|
|
975
|
-
|
|
1018
|
+
// Refresh git status and branches (branch switch also modifies working tree)
|
|
1019
|
+
const prevBranch = branchInfo?.current;
|
|
1020
|
+
await Promise.all([loadStatus(), loadBranches()]);
|
|
1021
|
+
|
|
1022
|
+
// If branch changed, also refresh remotes
|
|
1023
|
+
if (branchInfo?.current !== prevBranch) {
|
|
1024
|
+
loadRemotes();
|
|
1025
|
+
}
|
|
976
1026
|
|
|
977
1027
|
// Refresh the active diff tab if currently viewing one
|
|
978
|
-
if (activeTab && !activeTab.isLoading && activeTab.section !== 'commit') {
|
|
1028
|
+
if (activeTab && !activeTab.isLoading && activeTab.section !== 'commit' && activeTab.status !== '?') {
|
|
979
1029
|
const tab = activeTab;
|
|
980
1030
|
try {
|
|
981
1031
|
const action = tab.section === 'staged' ? 'git:diff-staged' : 'git:diff-unstaged';
|
|
@@ -1016,8 +1066,9 @@
|
|
|
1016
1066
|
const unsub = ws.on('git:changed', (payload: any) => {
|
|
1017
1067
|
if (payload.projectId !== projectId || !isRepo) return;
|
|
1018
1068
|
scheduleGitRefresh();
|
|
1019
|
-
//
|
|
1069
|
+
// Refresh branches and remotes in case of branch switch/create/delete
|
|
1020
1070
|
loadBranches();
|
|
1071
|
+
loadRemotes();
|
|
1021
1072
|
// Refresh log if it was already loaded (History tab was visited)
|
|
1022
1073
|
if (commits.length > 0) {
|
|
1023
1074
|
loadLog(true);
|
package/frontend/main.ts
CHANGED
|
@@ -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
|
}
|
|
@@ -32,7 +32,13 @@ const defaultSettings: AppSettings = {
|
|
|
32
32
|
soundNotifications: true,
|
|
33
33
|
pushNotifications: false,
|
|
34
34
|
layoutPresetVisibility: createDefaultPresetVisibility(),
|
|
35
|
-
fontSize: 13
|
|
35
|
+
fontSize: 13,
|
|
36
|
+
commitGenerator: {
|
|
37
|
+
useCustomModel: false,
|
|
38
|
+
engine: 'claude-code',
|
|
39
|
+
model: 'claude-code:haiku',
|
|
40
|
+
format: 'single-line'
|
|
41
|
+
}
|
|
36
42
|
};
|
|
37
43
|
|
|
38
44
|
// Default system settings
|
|
@@ -63,7 +69,12 @@ export function applyFontSize(size: number): void {
|
|
|
63
69
|
export function applyServerSettings(serverSettings: Partial<AppSettings> | null): void {
|
|
64
70
|
if (serverSettings && typeof serverSettings === 'object') {
|
|
65
71
|
// Merge with defaults to ensure all properties exist
|
|
66
|
-
|
|
72
|
+
const merged = { ...defaultSettings, ...serverSettings };
|
|
73
|
+
// Deep merge nested objects so new default fields are preserved
|
|
74
|
+
if (serverSettings.commitGenerator) {
|
|
75
|
+
merged.commitGenerator = { ...defaultSettings.commitGenerator, ...serverSettings.commitGenerator };
|
|
76
|
+
}
|
|
77
|
+
Object.assign(settings, merged);
|
|
67
78
|
applyFontSize(settings.fontSize);
|
|
68
79
|
debug.log('settings', 'Applied server settings');
|
|
69
80
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { IconName } from '$shared/types/ui/icons';
|
|
7
7
|
|
|
8
8
|
export type SettingsSection =
|
|
9
|
-
| '
|
|
9
|
+
| 'models'
|
|
10
10
|
| 'engines'
|
|
11
11
|
| 'appearance'
|
|
12
12
|
| 'notifications'
|
|
@@ -31,10 +31,10 @@ export interface SettingsSectionMeta {
|
|
|
31
31
|
|
|
32
32
|
export const settingsSections: SettingsSectionMeta[] = [
|
|
33
33
|
{
|
|
34
|
-
id: '
|
|
35
|
-
label: '
|
|
36
|
-
icon: 'lucide:
|
|
37
|
-
description: '
|
|
34
|
+
id: 'models',
|
|
35
|
+
label: 'Models',
|
|
36
|
+
icon: 'lucide:sparkles',
|
|
37
|
+
description: 'Chat and commit model'
|
|
38
38
|
},
|
|
39
39
|
{
|
|
40
40
|
id: 'appearance',
|
|
@@ -56,8 +56,8 @@ export const settingsSections: SettingsSectionMeta[] = [
|
|
|
56
56
|
},
|
|
57
57
|
{
|
|
58
58
|
id: 'engines',
|
|
59
|
-
label: '
|
|
60
|
-
icon: 'lucide:
|
|
59
|
+
label: 'Engines',
|
|
60
|
+
icon: 'lucide:plug',
|
|
61
61
|
description: 'Installation and accounts',
|
|
62
62
|
adminOnly: true
|
|
63
63
|
},
|
|
@@ -87,11 +87,11 @@ export const settingsSections: SettingsSectionMeta[] = [
|
|
|
87
87
|
// Create the state using Svelte 5 runes
|
|
88
88
|
export const settingsModalState = $state<SettingsModalState>({
|
|
89
89
|
isOpen: false,
|
|
90
|
-
activeSection: '
|
|
90
|
+
activeSection: 'models'
|
|
91
91
|
});
|
|
92
92
|
|
|
93
93
|
// Helper functions
|
|
94
|
-
export function openSettingsModal(section: SettingsSection = '
|
|
94
|
+
export function openSettingsModal(section: SettingsSection = 'models') {
|
|
95
95
|
settingsModalState.isOpen = true;
|
|
96
96
|
settingsModalState.activeSection = section;
|
|
97
97
|
}
|
|
@@ -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
|
+
}
|
|
@@ -18,6 +18,9 @@ interface UpdateState {
|
|
|
18
18
|
errorType: 'check' | 'update' | null;
|
|
19
19
|
updateOutput: string | null;
|
|
20
20
|
updateSuccess: boolean;
|
|
21
|
+
pendingRestart: boolean;
|
|
22
|
+
pendingVersions: { from: string; to: string } | null;
|
|
23
|
+
showRestartModal: boolean;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export const updateState = $state<UpdateState>({
|
|
@@ -30,7 +33,10 @@ export const updateState = $state<UpdateState>({
|
|
|
30
33
|
error: null,
|
|
31
34
|
errorType: null,
|
|
32
35
|
updateOutput: null,
|
|
33
|
-
updateSuccess: false
|
|
36
|
+
updateSuccess: false,
|
|
37
|
+
pendingRestart: false,
|
|
38
|
+
pendingVersions: null,
|
|
39
|
+
showRestartModal: false
|
|
34
40
|
});
|
|
35
41
|
|
|
36
42
|
let checkInterval: ReturnType<typeof setInterval> | null = null;
|
|
@@ -49,8 +55,17 @@ export async function checkForUpdate(): Promise<void> {
|
|
|
49
55
|
updateState.latestVersion = result.latestVersion;
|
|
50
56
|
updateState.updateAvailable = result.updateAvailable;
|
|
51
57
|
|
|
52
|
-
//
|
|
53
|
-
if (result.
|
|
58
|
+
// Restore pending restart state from backend (survives page refresh)
|
|
59
|
+
if (result.pendingRestart && result.pendingUpdate) {
|
|
60
|
+
updateState.pendingRestart = true;
|
|
61
|
+
updateState.pendingVersions = { from: result.pendingUpdate.fromVersion, to: result.pendingUpdate.toVersion };
|
|
62
|
+
updateState.updateSuccess = true;
|
|
63
|
+
updateState.latestVersion = result.pendingUpdate.toVersion;
|
|
64
|
+
updateState.showRestartModal = true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Auto-update if enabled and update is available (skip if already pending restart)
|
|
68
|
+
if (result.updateAvailable && systemSettings.autoUpdate && !result.pendingRestart) {
|
|
54
69
|
debug.log('server', 'Auto-update enabled, starting update...');
|
|
55
70
|
await runUpdate();
|
|
56
71
|
}
|
|
@@ -77,7 +92,10 @@ export async function runUpdate(): Promise<void> {
|
|
|
77
92
|
updateState.updateOutput = result.output;
|
|
78
93
|
updateState.updateSuccess = true;
|
|
79
94
|
updateState.updateAvailable = false;
|
|
95
|
+
updateState.pendingRestart = true;
|
|
96
|
+
updateState.pendingVersions = { from: updateState.currentVersion, to: result.newVersion };
|
|
80
97
|
updateState.latestVersion = result.newVersion;
|
|
98
|
+
updateState.showRestartModal = true;
|
|
81
99
|
|
|
82
100
|
debug.log('server', 'Update completed successfully');
|
|
83
101
|
} catch (err) {
|
|
@@ -89,11 +107,34 @@ export async function runUpdate(): Promise<void> {
|
|
|
89
107
|
}
|
|
90
108
|
}
|
|
91
109
|
|
|
92
|
-
/** Dismiss the update banner */
|
|
110
|
+
/** Dismiss the update banner (not allowed when restart is pending) */
|
|
93
111
|
export function dismissUpdate(): void {
|
|
112
|
+
if (updateState.pendingRestart) return;
|
|
94
113
|
updateState.dismissed = true;
|
|
95
114
|
}
|
|
96
115
|
|
|
116
|
+
/** Show the restart instructions modal */
|
|
117
|
+
export function showRestartModal(): void {
|
|
118
|
+
updateState.showRestartModal = true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Hide the restart instructions modal */
|
|
122
|
+
export function hideRestartModal(): void {
|
|
123
|
+
updateState.showRestartModal = false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Listen for update-completed broadcast (notifies other tabs/clients)
|
|
127
|
+
ws.on('system:update-completed', (payload) => {
|
|
128
|
+
debug.log('server', 'Update completed broadcast received');
|
|
129
|
+
updateState.updateSuccess = true;
|
|
130
|
+
updateState.updateAvailable = false;
|
|
131
|
+
updateState.pendingRestart = true;
|
|
132
|
+
updateState.pendingVersions = { from: payload.fromVersion, to: payload.toVersion };
|
|
133
|
+
updateState.latestVersion = payload.toVersion;
|
|
134
|
+
updateState.showRestartModal = true;
|
|
135
|
+
updateState.dismissed = false;
|
|
136
|
+
});
|
|
137
|
+
|
|
97
138
|
/** Start periodic update checks (every 30 minutes) */
|
|
98
139
|
export function startUpdateChecker(): void {
|
|
99
140
|
// Initial check after 5 seconds (let the app settle)
|
|
@@ -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.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",
|