@myrialabs/clopen 0.1.7 → 0.1.9

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.
Files changed (28) hide show
  1. package/backend/lib/database/migrations/023_create_user_unread_sessions_table.ts +32 -0
  2. package/backend/lib/database/migrations/index.ts +7 -0
  3. package/backend/lib/database/queries/session-queries.ts +37 -0
  4. package/backend/lib/git/git-service.ts +1 -0
  5. package/backend/ws/sessions/crud.ts +34 -2
  6. package/backend/ws/user/crud.ts +8 -4
  7. package/bun.lock +34 -12
  8. package/frontend/lib/components/common/MonacoEditor.svelte +6 -6
  9. package/frontend/lib/components/common/xterm/XTerm.svelte +27 -108
  10. package/frontend/lib/components/common/xterm/terminal-config.ts +2 -2
  11. package/frontend/lib/components/common/xterm/types.ts +1 -0
  12. package/frontend/lib/components/common/xterm/xterm-service.ts +69 -20
  13. package/frontend/lib/components/files/FileTree.svelte +4 -6
  14. package/frontend/lib/components/files/FileViewer.svelte +45 -101
  15. package/frontend/lib/components/git/CommitForm.svelte +1 -1
  16. package/frontend/lib/components/git/GitLog.svelte +141 -101
  17. package/frontend/lib/components/preview/browser/components/Toolbar.svelte +81 -72
  18. package/frontend/lib/components/settings/SettingsModal.svelte +1 -8
  19. package/frontend/lib/components/settings/engines/AIEnginesSettings.svelte +3 -3
  20. package/frontend/lib/components/terminal/Terminal.svelte +1 -1
  21. package/frontend/lib/components/terminal/TerminalTabs.svelte +28 -26
  22. package/frontend/lib/components/workspace/PanelHeader.svelte +639 -623
  23. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +3 -2
  24. package/frontend/lib/components/workspace/panels/GitPanel.svelte +34 -92
  25. package/frontend/lib/stores/core/app.svelte.ts +46 -0
  26. package/frontend/lib/stores/core/sessions.svelte.ts +24 -3
  27. package/frontend/lib/stores/ui/workspace.svelte.ts +14 -14
  28. package/package.json +8 -6
@@ -6,9 +6,12 @@
6
6
  */
7
7
 
8
8
  import { browser } from '$frontend/lib/app-environment';
9
- import type { Terminal } from 'xterm';
9
+ import type { Terminal } from '@xterm/xterm';
10
10
  import type { FitAddon } from '@xterm/addon-fit';
11
11
  import type { WebLinksAddon } from '@xterm/addon-web-links';
12
+ import type { ClipboardAddon } from '@xterm/addon-clipboard';
13
+ import type { Unicode11Addon } from '@xterm/addon-unicode11';
14
+ import type { LigaturesAddon } from '@xterm/addon-ligatures';
12
15
  import type { TerminalLine } from '$shared/types/terminal';
13
16
  import { terminalConfig } from './terminal-config';
14
17
  import { debug } from '$shared/utils/logger';
@@ -18,12 +21,16 @@ export class XTermService {
18
21
  public terminal: Terminal | null = null;
19
22
  public fitAddon: FitAddon | null = null;
20
23
  public webLinksAddon: WebLinksAddon | null = null;
24
+ public clipboardAddon: ClipboardAddon | null = null;
25
+ private unicode11Addon: Unicode11Addon | null = null;
26
+ private ligaturesAddon: LigaturesAddon | null = null;
21
27
  public isInitialized = false;
22
28
  public isReady = false;
23
29
 
24
30
  private sessionId: string | null = null;
25
31
  private resizeTimeout: ReturnType<typeof setTimeout> | null = null;
26
32
  private inputDisposable: any = null;
33
+ private lastSentDims: { cols: number; rows: number } | null = null;
27
34
 
28
35
  constructor() {
29
36
  // Service is stateless by design
@@ -49,27 +56,39 @@ export class XTermService {
49
56
  this.terminal = null;
50
57
  this.fitAddon = null;
51
58
  this.webLinksAddon = null;
59
+ this.clipboardAddon = null;
60
+ this.unicode11Addon = null;
61
+ this.ligaturesAddon = null;
52
62
  }
53
63
 
54
64
  try {
55
65
  debug.log('terminal', '🚀 Initializing XTerm...');
56
66
 
57
67
  // Dynamic import xterm classes
58
- const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
59
- import('xterm'),
68
+ const [{ Terminal }, { FitAddon }, { WebLinksAddon }, { ClipboardAddon }, { Unicode11Addon }] = await Promise.all([
69
+ import('@xterm/xterm'),
60
70
  import('@xterm/addon-fit'),
61
- import('@xterm/addon-web-links')
71
+ import('@xterm/addon-web-links'),
72
+ import('@xterm/addon-clipboard'),
73
+ import('@xterm/addon-unicode11')
62
74
  ]);
63
75
 
64
76
  // Create terminal instance
65
77
  this.terminal = new Terminal(terminalConfig);
66
78
 
67
- // Create and load addons
79
+ // Create and load core addons
68
80
  this.fitAddon = new FitAddon();
69
81
  this.webLinksAddon = new WebLinksAddon();
82
+ this.clipboardAddon = new ClipboardAddon();
83
+ this.unicode11Addon = new Unicode11Addon();
70
84
 
71
85
  this.terminal.loadAddon(this.fitAddon);
72
86
  this.terminal.loadAddon(this.webLinksAddon);
87
+ this.terminal.loadAddon(this.clipboardAddon);
88
+ this.terminal.loadAddon(this.unicode11Addon);
89
+
90
+ // Enable Unicode 11 for better character width support
91
+ this.terminal.unicode.activeVersion = '11';
73
92
 
74
93
  // Open terminal in container
75
94
  this.terminal.open(container);
@@ -82,6 +101,16 @@ export class XTermService {
82
101
  debug.log('terminal', '⚠️ Initial fit failed (container may have zero dimensions), will retry on resize');
83
102
  }
84
103
 
104
+ // Try ligatures addon for font ligature rendering (non-critical)
105
+ try {
106
+ const { LigaturesAddon } = await import('@xterm/addon-ligatures');
107
+ this.ligaturesAddon = new LigaturesAddon();
108
+ this.terminal.loadAddon(this.ligaturesAddon);
109
+ debug.log('terminal', '🔤 Font ligatures enabled');
110
+ } catch {
111
+ debug.log('terminal', '⚠️ Ligatures addon not available');
112
+ }
113
+
85
114
  this.isInitialized = true;
86
115
  this.isReady = true;
87
116
 
@@ -94,6 +123,9 @@ export class XTermService {
94
123
  }
95
124
  this.fitAddon = null;
96
125
  this.webLinksAddon = null;
126
+ this.clipboardAddon = null;
127
+ this.unicode11Addon = null;
128
+ this.ligaturesAddon = null;
97
129
  debug.error('terminal', '❌ Failed to initialize XTerm:', error);
98
130
  }
99
131
  }
@@ -180,11 +212,12 @@ export class XTermService {
180
212
  }
181
213
 
182
214
  /**
183
- * Update terminal font size and refit to container
215
+ * Update terminal font size, line height, and refit to container
184
216
  */
185
- updateFontSize(size: number, sessionId?: string): void {
217
+ updateFontSize(size: number, lineHeight: number, sessionId?: string): void {
186
218
  if (!this.terminal) return;
187
219
  this.terminal.options.fontSize = size;
220
+ this.terminal.options.lineHeight = lineHeight / size;
188
221
  this.fit(sessionId);
189
222
  }
190
223
 
@@ -262,6 +295,12 @@ export class XTermService {
262
295
  const dims = this.fitAddon.proposeDimensions();
263
296
 
264
297
  if (dims && sessionId) {
298
+ // Skip if dimensions haven't changed
299
+ if (this.lastSentDims && this.lastSentDims.cols === dims.cols && this.lastSentDims.rows === dims.rows) {
300
+ return;
301
+ }
302
+ this.lastSentDims = { cols: dims.cols, rows: dims.rows };
303
+
265
304
  // Notify backend of new terminal size via WebSocket HTTP
266
305
  debug.log('terminal', `🔧 Syncing terminal size: ${dims.cols}x${dims.rows}`);
267
306
  ws.http('terminal:resize', {
@@ -293,20 +332,11 @@ export class XTermService {
293
332
  scrollToBottomIfNearEnd(): void {
294
333
  if (!this.terminal) return;
295
334
 
296
- const viewport = (this.terminal as any)._core?.viewport;
297
- if (!viewport) {
298
- this.scrollToBottom();
299
- return;
300
- }
301
-
302
- const scrollTop = viewport._viewportElement.scrollTop;
303
- const scrollHeight = viewport._viewportElement.scrollHeight;
304
- const clientHeight = viewport._viewportElement.clientHeight;
335
+ const buffer = this.terminal.buffer.active;
336
+ const isNearBottom = buffer.viewportY >= buffer.baseY - 3;
305
337
 
306
- // If within 3 lines of bottom, scroll to bottom
307
- const threshold = this.terminal.rows * 3;
308
- if (scrollHeight - scrollTop - clientHeight < threshold) {
309
- this.scrollToBottom();
338
+ if (isNearBottom) {
339
+ this.terminal.scrollToBottom();
310
340
  }
311
341
  }
312
342
 
@@ -361,6 +391,21 @@ export class XTermService {
361
391
  };
362
392
  }
363
393
 
394
+ /**
395
+ * Paste text by sending to PTY via WebSocket
396
+ */
397
+ pasteText(text: string): void {
398
+ if (!this.sessionId || !text) return;
399
+ try {
400
+ ws.emit('terminal:input', {
401
+ sessionId: this.sessionId,
402
+ data: text
403
+ });
404
+ } catch (error) {
405
+ debug.error('terminal', '❌ Error pasting text:', error);
406
+ }
407
+ }
408
+
364
409
  /**
365
410
  * Cleanup terminal resources
366
411
  */
@@ -382,6 +427,10 @@ export class XTermService {
382
427
 
383
428
  this.fitAddon = null;
384
429
  this.webLinksAddon = null;
430
+ this.clipboardAddon = null;
431
+ this.unicode11Addon = null;
432
+ this.ligaturesAddon = null;
433
+ this.lastSentDims = null;
385
434
  this.isInitialized = false;
386
435
  this.isReady = false;
387
436
  }
@@ -420,9 +420,9 @@
420
420
 
421
421
  <div class="relative flex flex-col h-full overflow-hidden">
422
422
  <!-- Modern Header -->
423
- <div class="px-5 py-3 border-b border-slate-200 dark:border-slate-700">
423
+ <div class="px-5 py-2.5 border-b border-slate-200 dark:border-slate-700">
424
424
  <div class="flex items-start justify-between gap-2">
425
- <div class="flex-1 min-w-0">
425
+ <div class="flex-1 min-w-0" title={projectState.currentProject?.path}>
426
426
  <h3 class="text-sm font-bold text-slate-900 dark:text-slate-100">
427
427
  {projectState.currentProject?.name}
428
428
  </h3>
@@ -466,9 +466,8 @@
466
466
  <div class="relative flex border-b border-slate-200 dark:border-slate-700">
467
467
  <button
468
468
  onclick={() => { searchVisible = false; }}
469
- class="relative flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors {!searchVisible ? 'text-violet-600 dark:text-violet-400' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
469
+ class="relative flex-1 flex items-center justify-center px-3 py-2 text-xs font-medium transition-colors {!searchVisible ? 'text-violet-600 dark:text-violet-400' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
470
470
  >
471
- <Icon name="lucide:folder" class="w-3.5 h-3.5" />
472
471
  Explorer
473
472
  {#if !searchVisible}
474
473
  <span class="absolute bottom-0 inset-x-0 h-px bg-violet-600 dark:bg-violet-400"></span>
@@ -476,9 +475,8 @@
476
475
  </button>
477
476
  <button
478
477
  onclick={switchToSearch}
479
- class="relative flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-colors {searchVisible ? 'text-violet-600 dark:text-violet-400' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
478
+ class="relative flex-1 flex items-center justify-center px-3 py-2 text-xs font-medium transition-colors {searchVisible ? 'text-violet-600 dark:text-violet-400' : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
480
479
  >
481
- <Icon name="lucide:search" class="w-3.5 h-3.5" />
482
480
  Search
483
481
  {#if searchVisible}
484
482
  <span class="absolute bottom-0 inset-x-0 h-px bg-violet-600 dark:bg-violet-400"></span>
@@ -85,33 +85,8 @@
85
85
  // PDF blob URL
86
86
  let pdfBlobUrl = $state<string | null>(null);
87
87
 
88
- // Container ref for measuring height
89
- let containerRef = $state<HTMLDivElement | null>(null);
90
- let editorHeight = $state('0px'); // Default height
91
-
92
- // Update editor height based on container
93
- function updateEditorHeight() {
94
- if (containerRef) {
95
- const rect = containerRef.getBoundingClientRect();
96
- // Get actual available height
97
- const availableHeight = rect.height;
98
- if (availableHeight > 0) {
99
- const adjustedHeight = availableHeight;
100
- editorHeight = `${Math.max(200, adjustedHeight)}px`;
101
- } else {
102
- const viewportHeight = window.innerHeight;
103
- const estimatedHeight = viewportHeight - 200;
104
- editorHeight = `${Math.max(400, estimatedHeight)}px`;
105
- }
106
- }
107
- }
108
-
109
- // Update height on mount and resize
88
+ // Keyboard shortcut for save
110
89
  onMount(() => {
111
- setTimeout(updateEditorHeight, 100);
112
-
113
- window.addEventListener('resize', updateEditorHeight);
114
-
115
90
  function handleKeyDown(e: KeyboardEvent) {
116
91
  if ((e.ctrlKey || e.metaKey) && e.key === 's') {
117
92
  e.preventDefault();
@@ -123,21 +98,7 @@
123
98
 
124
99
  window.addEventListener('keydown', handleKeyDown);
125
100
 
126
- if (containerRef && typeof ResizeObserver !== 'undefined') {
127
- const resizeObserver = new ResizeObserver(() => {
128
- updateEditorHeight();
129
- });
130
- resizeObserver.observe(containerRef);
131
-
132
- return () => {
133
- resizeObserver.disconnect();
134
- window.removeEventListener('resize', updateEditorHeight);
135
- window.removeEventListener('keydown', handleKeyDown);
136
- };
137
- }
138
-
139
101
  return () => {
140
- window.removeEventListener('resize', updateEditorHeight);
141
102
  window.removeEventListener('keydown', handleKeyDown);
142
103
  };
143
104
  });
@@ -152,14 +113,6 @@
152
113
  }
153
114
  });
154
115
 
155
- // Update height when container ref changes
156
- $effect(() => {
157
- if (containerRef) {
158
- updateEditorHeight();
159
- }
160
- });
161
-
162
-
163
116
  // Load binary content (images, PDF) via WebSocket when file changes
164
117
  $effect(() => {
165
118
  if (file && (isImageFile(file.name) || isPdfFile(file.name))) {
@@ -577,38 +530,35 @@
577
530
  </div>
578
531
  {:else}
579
532
  <!-- SVG code view (editable) -->
580
- <div class="h-full flex flex-col" bind:this={containerRef}>
581
- <div class="flex-1 bg-slate-50 dark:bg-slate-950 overflow-hidden">
582
- <div class="h-full flex flex-col">
583
- <div class="flex-1">
584
- {#key themeKey}
585
- <MonacoEditor
586
- bind:this={monacoEditorRef}
587
- bind:value={editableContent}
588
- language="xml"
589
- readonly={false}
590
- onChange={handleContentChange}
591
- height={editorHeight}
592
- options={{
593
- minimap: { enabled: false },
594
- wordWrap: 'off',
595
- renderWhitespace: 'none',
596
- mouseWheelZoom: false
597
- }}
598
- />
599
- {/key}
600
- </div>
601
-
602
- {#if hasChanges}
603
- <div class="flex-shrink-0 p-4 bg-amber-50 dark:bg-amber-900/30 border-t border-amber-200 dark:border-amber-800">
604
- <div class="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1">
605
- <Icon name="lucide:circle-alert" class="w-3 h-3" />
606
- Unsaved changes
607
- </div>
608
- </div>
609
- {/if}
533
+ <div class="h-full flex flex-col bg-slate-50 dark:bg-slate-950">
534
+ <div class="flex-1 relative overflow-hidden">
535
+ <div class="absolute inset-0">
536
+ {#key themeKey}
537
+ <MonacoEditor
538
+ bind:this={monacoEditorRef}
539
+ bind:value={editableContent}
540
+ language="xml"
541
+ readonly={false}
542
+ onChange={handleContentChange}
543
+ options={{
544
+ minimap: { enabled: false },
545
+ wordWrap: 'off',
546
+ renderWhitespace: 'none',
547
+ mouseWheelZoom: false
548
+ }}
549
+ />
550
+ {/key}
610
551
  </div>
611
552
  </div>
553
+
554
+ {#if hasChanges}
555
+ <div class="flex-shrink-0 p-4 bg-amber-50 dark:bg-amber-900/30 border-t border-amber-200 dark:border-amber-800">
556
+ <div class="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1">
557
+ <Icon name="lucide:circle-alert" class="w-3 h-3" />
558
+ Unsaved changes
559
+ </div>
560
+ </div>
561
+ {/if}
612
562
  </div>
613
563
  {/if}
614
564
  {:else if isPdfFile(file.name)}
@@ -644,29 +594,23 @@
644
594
  </div>
645
595
  {:else}
646
596
  <!-- Code content (always in edit mode) -->
647
- <div class="h-full flex flex-col" bind:this={containerRef}>
648
- <div class="flex-1 bg-slate-50 dark:bg-slate-950 overflow-hidden">
649
- <div class="h-full flex flex-col">
650
- <div class="flex-1">
651
- {#key themeKey}
652
- <MonacoEditor
653
- bind:this={monacoEditorRef}
654
- bind:value={editableContent}
655
- language={getDetectedLanguage()}
656
- readonly={false}
657
- onChange={handleContentChange}
658
- height={editorHeight}
659
- options={{
660
- minimap: { enabled: false },
661
- wordWrap: wordWrap ? 'on' : 'off',
662
- renderWhitespace: 'none',
663
- mouseWheelZoom: false
664
- }}
665
- />
666
- {/key}
667
- </div>
668
-
669
- </div>
597
+ <div class="h-full relative bg-slate-50 dark:bg-slate-950">
598
+ <div class="absolute inset-0">
599
+ {#key themeKey}
600
+ <MonacoEditor
601
+ bind:this={monacoEditorRef}
602
+ bind:value={editableContent}
603
+ language={getDetectedLanguage()}
604
+ readonly={false}
605
+ onChange={handleContentChange}
606
+ options={{
607
+ minimap: { enabled: false },
608
+ wordWrap: wordWrap ? 'on' : 'off',
609
+ renderWhitespace: 'none',
610
+ mouseWheelZoom: false
611
+ }}
612
+ />
613
+ {/key}
670
614
  </div>
671
615
  </div>
672
616
  {/if}
@@ -42,7 +42,7 @@
42
42
  }
43
43
  </script>
44
44
 
45
- <div class="px-2 pb-2">
45
+ <div class="px-2 py-2">
46
46
  <div class="flex flex-col gap-1.5">
47
47
  <textarea
48
48
  bind:this={textareaEl}