generated from nhcarrigan/template
e45a1a1c98
## Summary - Add CodeMirror 6 editor with syntax highlighting for 40+ languages - Add file browser sidebar with collapsible directory tree navigation - Add multi-tab support with dirty state indicators and close buttons - Add keyboard shortcuts (Ctrl+E toggle, Ctrl+B file browser, Ctrl+S save, Ctrl+W close tab) - Add editor toggle button to status bar (disabled when not connected) - Editor automatically uses current session's working directory - Add Tauri backend commands for file operations (list_directory, read_file_content, write_file_content) ## Test Plan - [ ] Connect to a session and verify the editor toggle button becomes enabled - [ ] Press Ctrl+E to open the editor and verify file tree shows the session's CWD - [ ] Navigate directories and open files to verify syntax highlighting works - [ ] Edit a file and verify the dirty indicator (*) appears - [ ] Save with Ctrl+S and verify the dirty indicator disappears - [ ] Open multiple files and verify tab switching works - [ ] Close tabs with Ctrl+W or the X button - [ ] Disconnect and verify the editor automatically closes - [ ] Verify keyboard shortcuts are documented in the shortcuts modal Closes #72 ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #79 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
209 lines
4.6 KiB
Svelte
209 lines
4.6 KiB
Svelte
<script lang="ts">
|
|
import type { FileEntry } from "$lib/types/editor";
|
|
import { editorStore } from "$lib/stores/editor";
|
|
import Self from "./FileTreeItem.svelte";
|
|
|
|
interface Props {
|
|
entry: FileEntry;
|
|
depth?: number;
|
|
onContextMenu?: (event: MouseEvent, entry: FileEntry) => void;
|
|
}
|
|
|
|
let { entry, depth = 0, onContextMenu }: Props = $props();
|
|
|
|
function handleClick() {
|
|
if (entry.isDirectory) {
|
|
editorStore.toggleDirectory(entry);
|
|
} else {
|
|
editorStore.openFile(entry.path);
|
|
}
|
|
}
|
|
|
|
function handleKeydown(event: KeyboardEvent) {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
handleClick();
|
|
}
|
|
}
|
|
|
|
function handleContextMenu(event: MouseEvent) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onContextMenu?.(event, entry);
|
|
}
|
|
|
|
const isExpanded = $derived(entry.isExpanded ?? false);
|
|
const isLoading = $derived(entry.isLoading ?? false);
|
|
const children = $derived(entry.children ?? []);
|
|
</script>
|
|
|
|
<div class="file-tree-item">
|
|
<button
|
|
class="item-row"
|
|
class:directory={entry.isDirectory}
|
|
class:file={!entry.isDirectory}
|
|
style="padding-left: {depth * 16 + 8}px"
|
|
onclick={handleClick}
|
|
onkeydown={handleKeydown}
|
|
oncontextmenu={handleContextMenu}
|
|
title={entry.path}
|
|
>
|
|
{#if entry.isDirectory}
|
|
<span class="icon">
|
|
{#if isLoading}
|
|
<svg
|
|
class="spinner"
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="32">
|
|
<animate
|
|
attributeName="stroke-dashoffset"
|
|
dur="1s"
|
|
values="32;0"
|
|
repeatCount="indefinite"
|
|
/>
|
|
</circle>
|
|
</svg>
|
|
{:else if isExpanded}
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M6 9l6 6 6-6" />
|
|
</svg>
|
|
{:else}
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M9 6l6 6-6 6" />
|
|
</svg>
|
|
{/if}
|
|
</span>
|
|
<span class="folder-icon">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
<path
|
|
d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
{:else}
|
|
<span class="icon spacer"></span>
|
|
<span class="file-icon">
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
<polyline points="14 2 14 8 20 8" />
|
|
</svg>
|
|
</span>
|
|
{/if}
|
|
<span class="name">{entry.name}</span>
|
|
</button>
|
|
|
|
{#if entry.isDirectory && isExpanded && children.length > 0}
|
|
<div class="children">
|
|
{#each children as child (child.path)}
|
|
<Self entry={child} depth={depth + 1} {onContextMenu} />
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.file-tree-item {
|
|
user-select: none;
|
|
}
|
|
|
|
.item-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
width: 100%;
|
|
padding: 4px 8px;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-primary);
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
border-radius: 4px;
|
|
transition: background-color 0.15s ease;
|
|
}
|
|
|
|
.item-row:hover {
|
|
background-color: var(--bg-secondary);
|
|
}
|
|
|
|
.item-row:focus {
|
|
outline: 1px solid var(--accent-primary);
|
|
outline-offset: -1px;
|
|
}
|
|
|
|
.icon {
|
|
flex-shrink: 0;
|
|
width: 14px;
|
|
height: 14px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.icon.spacer {
|
|
visibility: hidden;
|
|
}
|
|
|
|
.folder-icon {
|
|
flex-shrink: 0;
|
|
color: var(--accent-primary);
|
|
}
|
|
|
|
.file-icon {
|
|
flex-shrink: 0;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.name {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.children {
|
|
margin-left: 0;
|
|
}
|
|
|
|
.spinner {
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
from {
|
|
transform: rotate(0deg);
|
|
}
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
</style>
|