From 59606e067f9f9253836966bdf31a5eb9467c24a3 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Tue, 10 Mar 2026 02:51:05 +0100 Subject: [PATCH] feat(files): add word wrap, collapsible sidebar, and preview/pinned tabs to FilesTab --- .../lib/components/Workspace/FilesTab.svelte | 372 +++++++++++++++--- 1 file changed, 321 insertions(+), 51 deletions(-) diff --git a/v2/src/lib/components/Workspace/FilesTab.svelte b/v2/src/lib/components/Workspace/FilesTab.svelte index 716215f..79ede99 100644 --- a/v2/src/lib/components/Workspace/FilesTab.svelte +++ b/v2/src/lib/components/Workspace/FilesTab.svelte @@ -16,13 +16,31 @@ depth: number; } + // Open file tab + interface FileTab { + path: string; + name: string; + pinned: boolean; + content: FileContent | null; + } + let roots = $state([]); let expandedPaths = $state>(new Set()); - let selectedPath = $state(null); - let fileContent = $state(null); - let fileLoading = $state(false); let highlighterReady = $state(false); + // Tab state: open file tabs + active tab + let fileTabs = $state([]); + let activeTabPath = $state(null); + let fileLoading = $state(false); + + // Sidebar state + let sidebarCollapsed = $state(false); + let sidebarWidth = $state(14); // rem + let resizing = $state(false); + + // Derived: active tab's content + let activeTab = $derived(fileTabs.find(t => t.path === activeTabPath) ?? null); + // Load root directory $effect(() => { const dir = cwd; @@ -48,7 +66,6 @@ next.delete(path); expandedPaths = next; } else { - // Load children if not yet loaded if (!node.children) { node.loading = true; const entries = await loadDirectory(path); @@ -59,22 +76,75 @@ } } - async function selectFile(node: TreeNode) { + /** Single click: preview file (replaces existing preview tab) */ + async function previewFile(node: TreeNode) { if (node.is_dir) { toggleDir(node); return; } - selectedPath = node.path; + // If already open as pinned tab, just focus it + const existing = fileTabs.find(t => t.path === node.path); + if (existing?.pinned) { + activeTabPath = node.path; + return; + } + + // Replace any existing preview (unpinned) tab + const previewIdx = fileTabs.findIndex(t => !t.pinned); + const tab: FileTab = { + path: node.path, + name: node.name, + pinned: false, + content: null, + }; + + if (existing) { + // Already the preview tab, just refocus + activeTabPath = node.path; + return; + } + + if (previewIdx >= 0) { + fileTabs[previewIdx] = tab; + } else { + fileTabs = [...fileTabs, tab]; + } + activeTabPath = node.path; + + // Load content fileLoading = true; try { - fileContent = await readFileContent(node.path); + tab.content = await readFileContent(node.path); } catch (e) { - fileContent = { type: 'Binary', message: `Error: ${e}` }; + tab.content = { type: 'Binary', message: `Error: ${e}` }; } finally { fileLoading = false; } } + /** Double click: pin the file as a permanent tab */ + function pinFile(node: TreeNode) { + if (node.is_dir) return; + const existing = fileTabs.find(t => t.path === node.path); + if (existing) { + existing.pinned = true; + activeTabPath = node.path; + } else { + // Open and pin directly + previewFile(node).then(() => { + const tab = fileTabs.find(t => t.path === node.path); + if (tab) tab.pinned = true; + }); + } + } + + function closeTab(path: string) { + fileTabs = fileTabs.filter(t => t.path !== path); + if (activeTabPath === path) { + activeTabPath = fileTabs[fileTabs.length - 1]?.path ?? null; + } + } + function flattenTree(nodes: TreeNode[]): TreeNode[] { const result: TreeNode[] = []; for (const node of nodes) { @@ -124,67 +194,126 @@ const ext = path.split('.').pop()?.toLowerCase() ?? ''; return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico', 'bmp'].includes(ext); } + + // Drag-resize sidebar + function startResize(e: MouseEvent) { + e.preventDefault(); + resizing = true; + const startX = e.clientX; + const startWidth = sidebarWidth; + + function onMove(ev: MouseEvent) { + const delta = ev.clientX - startX; + const newWidth = startWidth + delta / 16; // convert px to rem (approx) + sidebarWidth = Math.max(8, Math.min(30, newWidth)); + } + + function onUp() { + resizing = false; + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + } + + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }
- +
+
+ {#each flatNodes as node (node.path)} + + {/each} + {#if flatNodes.length === 0} +
No files
+ {/if} +
+ + +
+ {:else} + + {/if}
- {#if fileLoading} + + {#if fileTabs.length > 0} +
+ {#each fileTabs as tab (tab.path)} + + + {/each} +
+ {/if} + + + {#if fileLoading && activeTabPath && !activeTab?.content}
Loading…
- {:else if !selectedPath} + {:else if !activeTab}
Select a file to view
- {:else if fileContent?.type === 'TooLarge'} + {:else if activeTab.content?.type === 'TooLarge'}
File too large - {formatSize(fileContent.size)} + {formatSize(activeTab.content.size)}
- {:else if fileContent?.type === 'Binary'} - {#if isImageExt(selectedPath)} + {:else if activeTab.content?.type === 'Binary'} + {#if isImageExt(activeTab.path)}
- {selectedPath.split('/').pop()} + {activeTab.name}
{:else} -
{fileContent.message}
+
{activeTab.content.message}
{/if} - {:else if fileContent?.type === 'Text'} + {:else if activeTab.content?.type === 'Text'}
- {#if fileContent.lang === 'csv'} -
{fileContent.content}
+ {#if activeTab.content.lang === 'csv'} +
{activeTab.content.content}
{:else} - {@html renderHighlighted(fileContent.content, fileContent.lang)} + {@html renderHighlighted(activeTab.content.content, activeTab.content.lang)} {/if}
{/if} - {#if selectedPath} -
{selectedPath}
+ + {#if activeTab} +
{activeTab.path}
{/if}
@@ -197,20 +326,26 @@ flex: 1; } + /* --- Sidebar --- */ + .tree-sidebar { - width: 14rem; flex-shrink: 0; background: var(--ctp-mantle); border-right: 1px solid var(--ctp-surface0); display: flex; flex-direction: column; overflow: hidden; + min-width: 8rem; + max-width: 30rem; } .tree-header { padding: 0.375rem 0.625rem; border-bottom: 1px solid var(--ctp-surface0); flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; } .tree-title { @@ -221,6 +356,49 @@ color: var(--ctp-overlay1); } + .collapse-btn, .expand-btn { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: var(--ctp-overlay1); + cursor: pointer; + padding: 0.125rem; + border-radius: 0.1875rem; + transition: color 0.12s, background 0.12s; + } + + .collapse-btn:hover, .expand-btn:hover { + color: var(--ctp-text); + background: var(--ctp-surface0); + } + + .expand-btn { + flex-shrink: 0; + width: 1.5rem; + height: 100%; + background: var(--ctp-mantle); + border-right: 1px solid var(--ctp-surface0); + border-radius: 0; + padding: 0; + } + + .resize-handle { + width: 4px; + cursor: col-resize; + background: transparent; + flex-shrink: 0; + transition: background 0.15s; + margin-left: -2px; + margin-right: -2px; + z-index: 1; + } + + .resize-handle:hover, .resize-handle.active { + background: var(--ctp-blue); + } + .tree-list { flex: 1; overflow-y: auto; @@ -293,6 +471,82 @@ text-align: center; } + /* --- File tab bar --- */ + + .file-tab-bar { + display: flex; + background: var(--ctp-mantle); + border-bottom: 1px solid var(--ctp-surface0); + flex-shrink: 0; + overflow-x: auto; + scrollbar-width: none; + } + + .file-tab { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.375rem 0.25rem 0.625rem; + border: none; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--ctp-overlay1); + font-size: 0.675rem; + cursor: pointer; + white-space: nowrap; + transition: color 0.1s, background 0.1s; + max-width: 10rem; + } + + .file-tab:hover { + background: var(--ctp-surface0); + color: var(--ctp-subtext1); + } + + .file-tab.active { + background: var(--ctp-base); + color: var(--ctp-text); + border-bottom-color: var(--accent, var(--ctp-blue)); + } + + .file-tab-name { + overflow: hidden; + text-overflow: ellipsis; + } + + .file-tab-name.italic { + font-style: italic; + } + + .file-tab-close { + display: flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + border: none; + background: transparent; + color: var(--ctp-overlay0); + font-size: 0.75rem; + cursor: pointer; + border-radius: 0.125rem; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.1s, background 0.1s; + } + + .file-tab:hover .file-tab-close, + .file-tab.active .file-tab-close { + opacity: 1; + } + + .file-tab-close:hover { + background: var(--ctp-surface1); + color: var(--ctp-text); + } + + /* --- Viewer --- */ + .file-viewer { flex: 1; min-width: 0; @@ -334,12 +588,18 @@ font-size: 0.775rem; line-height: 1.55; color: var(--ctp-text); + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; } .viewer-code :global(code) { font-family: inherit; background: none; padding: 0; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; } .viewer-code :global(.shiki) { @@ -348,12 +608,22 @@ margin: 0; border: none; box-shadow: none; + white-space: pre-wrap !important; + word-wrap: break-word; + overflow-wrap: break-word; + } + + .viewer-code :global(.shiki code) { + white-space: pre-wrap !important; + word-wrap: break-word; + overflow-wrap: break-word; } .csv-content { font-family: var(--term-font-family, monospace); font-size: 0.75rem; - white-space: pre; + white-space: pre-wrap; + word-wrap: break-word; tab-size: 4; }