generated from nhcarrigan/template
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>
This commit was merged in pull request #79.
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user