Files
hikari-desktop/src/lib/components/editor/FileTreeItem.svelte
T
hikari e45a1a1c98
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Build Linux (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
feat: add built-in file editor with syntax highlighting (#79)
## 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>
2026-01-28 18:20:02 -08:00

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>