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>
359 lines
8.7 KiB
Svelte
359 lines
8.7 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from "svelte";
|
|
import { editorStore } from "$lib/stores/editor";
|
|
import FileTreeItem from "./FileTreeItem.svelte";
|
|
import FileContextMenu from "./FileContextMenu.svelte";
|
|
import InputDialog from "./InputDialog.svelte";
|
|
import ConfirmDialog from "./ConfirmDialog.svelte";
|
|
import type { FileEntry } from "$lib/types/editor";
|
|
|
|
const fileTree = editorStore.fileTree;
|
|
const isLoadingTree = editorStore.isLoadingTree;
|
|
const currentDirectory = editorStore.currentDirectory;
|
|
|
|
// Listen for Ctrl+N keyboard shortcut from +page.svelte
|
|
function handleNewFileEvent() {
|
|
handleNewFile();
|
|
}
|
|
|
|
onMount(() => {
|
|
window.addEventListener("editor-new-file", handleNewFileEvent);
|
|
});
|
|
|
|
onDestroy(() => {
|
|
window.removeEventListener("editor-new-file", handleNewFileEvent);
|
|
});
|
|
|
|
// Context menu state
|
|
let contextMenu = $state<{
|
|
show: boolean;
|
|
x: number;
|
|
y: number;
|
|
targetEntry: FileEntry | null;
|
|
}>({
|
|
show: false,
|
|
x: 0,
|
|
y: 0,
|
|
targetEntry: null,
|
|
});
|
|
|
|
// Dialog state
|
|
let dialog = $state<{
|
|
type: "newFile" | "newFolder" | "delete" | "rename" | null;
|
|
parentPath: string;
|
|
targetEntry: FileEntry | null;
|
|
}>({
|
|
type: null,
|
|
parentPath: "",
|
|
targetEntry: null,
|
|
});
|
|
|
|
function handleRefresh() {
|
|
const dir = $currentDirectory;
|
|
if (dir) {
|
|
editorStore.initializeFileTree(dir);
|
|
}
|
|
}
|
|
|
|
function handleContextMenu(event: MouseEvent, entry: FileEntry | null = null) {
|
|
event.preventDefault();
|
|
contextMenu = {
|
|
show: true,
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
targetEntry: entry,
|
|
};
|
|
}
|
|
|
|
function closeContextMenu() {
|
|
contextMenu = { show: false, x: 0, y: 0, targetEntry: null };
|
|
}
|
|
|
|
function openNewFileDialog(parentPath: string) {
|
|
dialog = { type: "newFile", parentPath, targetEntry: null };
|
|
}
|
|
|
|
function openNewFolderDialog(parentPath: string) {
|
|
dialog = { type: "newFolder", parentPath, targetEntry: null };
|
|
}
|
|
|
|
function openDeleteDialog(entry: FileEntry) {
|
|
dialog = { type: "delete", parentPath: "", targetEntry: entry };
|
|
}
|
|
|
|
function openRenameDialog(entry: FileEntry) {
|
|
dialog = { type: "rename", parentPath: "", targetEntry: entry };
|
|
}
|
|
|
|
function closeDialog() {
|
|
dialog = { type: null, parentPath: "", targetEntry: null };
|
|
}
|
|
|
|
async function handleCreateFile(fileName: string) {
|
|
await editorStore.createFile(dialog.parentPath, fileName);
|
|
closeDialog();
|
|
}
|
|
|
|
async function handleCreateFolder(folderName: string) {
|
|
await editorStore.createDirectory(dialog.parentPath, folderName);
|
|
closeDialog();
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (!dialog.targetEntry) return;
|
|
|
|
if (dialog.targetEntry.isDirectory) {
|
|
await editorStore.deleteDirectory(dialog.targetEntry.path);
|
|
} else {
|
|
await editorStore.deleteFile(dialog.targetEntry.path);
|
|
}
|
|
closeDialog();
|
|
}
|
|
|
|
async function handleRename(newName: string) {
|
|
if (!dialog.targetEntry) return;
|
|
await editorStore.renamePath(dialog.targetEntry.path, newName);
|
|
closeDialog();
|
|
}
|
|
|
|
function handleNewFile() {
|
|
openNewFileDialog($currentDirectory);
|
|
}
|
|
|
|
function handleNewFolder() {
|
|
openNewFolderDialog($currentDirectory);
|
|
}
|
|
</script>
|
|
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div class="file-browser" oncontextmenu={(e) => handleContextMenu(e, null)}>
|
|
<div class="header">
|
|
<span class="title">Files</span>
|
|
<div class="header-buttons">
|
|
<button class="header-button" onclick={handleNewFile} title="New File (Ctrl+N)">
|
|
<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" />
|
|
<path d="M14 2v6h6" />
|
|
<path d="M12 18v-6" />
|
|
<path d="M9 15h6" />
|
|
</svg>
|
|
</button>
|
|
<button class="header-button" onclick={handleNewFolder} title="New Folder">
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
|
|
<path d="M12 11v6" />
|
|
<path d="M9 14h6" />
|
|
</svg>
|
|
</button>
|
|
<button class="header-button" onclick={handleRefresh} title="Refresh file tree">
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
>
|
|
<path d="M23 4v6h-6" />
|
|
<path d="M1 20v-6h6" />
|
|
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="tree-container">
|
|
{#if $isLoadingTree}
|
|
<div class="loading">
|
|
<svg
|
|
class="spinner"
|
|
width="20"
|
|
height="20"
|
|
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>
|
|
<span>Loading...</span>
|
|
</div>
|
|
{:else if $fileTree.length === 0}
|
|
<div class="empty">
|
|
<span>No files found</span>
|
|
</div>
|
|
{:else}
|
|
<div class="tree">
|
|
{#each $fileTree as entry (entry.path)}
|
|
<FileTreeItem {entry} onContextMenu={handleContextMenu} />
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
{#if contextMenu.show}
|
|
<FileContextMenu
|
|
x={contextMenu.x}
|
|
y={contextMenu.y}
|
|
targetEntry={contextMenu.targetEntry}
|
|
currentDirectory={$currentDirectory}
|
|
onNewFile={openNewFileDialog}
|
|
onNewFolder={openNewFolderDialog}
|
|
onRename={openRenameDialog}
|
|
onDelete={openDeleteDialog}
|
|
onClose={closeContextMenu}
|
|
/>
|
|
{/if}
|
|
|
|
{#if dialog.type === "newFile"}
|
|
<InputDialog
|
|
title="New File"
|
|
placeholder="Enter file name..."
|
|
confirmText="Create"
|
|
onConfirm={handleCreateFile}
|
|
onCancel={closeDialog}
|
|
/>
|
|
{/if}
|
|
|
|
{#if dialog.type === "newFolder"}
|
|
<InputDialog
|
|
title="New Folder"
|
|
placeholder="Enter folder name..."
|
|
confirmText="Create"
|
|
onConfirm={handleCreateFolder}
|
|
onCancel={closeDialog}
|
|
/>
|
|
{/if}
|
|
|
|
{#if dialog.type === "delete" && dialog.targetEntry}
|
|
<ConfirmDialog
|
|
title="Delete {dialog.targetEntry.isDirectory ? 'Folder' : 'File'}"
|
|
message="Are you sure you want to delete '{dialog.targetEntry.name}'? {dialog.targetEntry
|
|
.isDirectory
|
|
? 'This will also delete all files and folders inside it.'
|
|
: 'This action cannot be undone.'}"
|
|
confirmText="Delete"
|
|
destructive={true}
|
|
onConfirm={handleDelete}
|
|
onCancel={closeDialog}
|
|
/>
|
|
{/if}
|
|
|
|
{#if dialog.type === "rename" && dialog.targetEntry}
|
|
<InputDialog
|
|
title="Rename {dialog.targetEntry.isDirectory ? 'Folder' : 'File'}"
|
|
placeholder="Enter new name..."
|
|
confirmText="Rename"
|
|
initialValue={dialog.targetEntry.name}
|
|
onConfirm={handleRename}
|
|
onCancel={closeDialog}
|
|
/>
|
|
{/if}
|
|
|
|
<style>
|
|
.file-browser {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
background-color: var(--bg-primary);
|
|
border-right: 1px solid var(--border-color);
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
background-color: var(--bg-secondary);
|
|
}
|
|
|
|
.title {
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
color: var(--text-secondary);
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.header-buttons {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
|
|
.header-button {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 4px;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.header-button:hover {
|
|
background-color: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.tree-container {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 4px 0;
|
|
}
|
|
|
|
.tree {
|
|
min-width: max-content;
|
|
}
|
|
|
|
.loading,
|
|
.empty {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
padding: 24px;
|
|
color: var(--text-secondary);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.spinner {
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
from {
|
|
transform: rotate(0deg);
|
|
}
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
</style>
|