feat: add built-in file editor with syntax highlighting #79

Merged
naomi merged 6 commits from feat/editor into main 2026-01-28 18:20:02 -08:00
10 changed files with 660 additions and 26 deletions
Showing only changes of commit d6d43a8abe - Show all commits
+72
View File
@@ -463,6 +463,78 @@ pub async fn write_file_content(path: String, content: String) -> Result<(), Str
.map_err(|e| format!("Failed to write file: {}", e))
}
#[tauri::command]
pub async fn create_file(path: String) -> Result<(), String> {
use std::fs::File;
use std::path::Path;
let file_path = Path::new(&path);
if file_path.exists() {
return Err("File already exists".to_string());
}
File::create(file_path).map_err(|e| format!("Failed to create file: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn create_directory(path: String) -> Result<(), String> {
use std::fs;
use std::path::Path;
let dir_path = Path::new(&path);
if dir_path.exists() {
return Err("Directory already exists".to_string());
}
fs::create_dir_all(dir_path).map_err(|e| format!("Failed to create directory: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn delete_file(path: String) -> Result<(), String> {
use std::fs;
use std::path::Path;
let file_path = Path::new(&path);
if !file_path.exists() {
return Err("File does not exist".to_string());
}
if file_path.is_dir() {
return Err("Path is a directory, use delete_directory instead".to_string());
}
fs::remove_file(file_path).map_err(|e| format!("Failed to delete file: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn delete_directory(path: String) -> Result<(), String> {
use std::fs;
use std::path::Path;
let dir_path = Path::new(&path);
if !dir_path.exists() {
return Err("Directory does not exist".to_string());
}
if !dir_path.is_dir() {
return Err("Path is not a directory".to_string());
}
fs::remove_dir_all(dir_path).map_err(|e| format!("Failed to delete directory: {}", e))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
+4
View File
@@ -154,6 +154,10 @@ pub fn run() {
list_directory,
read_file_content,
write_file_content,
create_file,
create_directory,
delete_file,
delete_directory,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@@ -34,6 +34,8 @@
{ keys: ["Ctrl", "B"], description: "Toggle file browser" },
{ keys: ["Ctrl", "S"], description: "Save current file" },
{ keys: ["Ctrl", "W"], description: "Close current tab" },
{ keys: ["Ctrl", "N"], description: "New file" },
{ keys: ["Right-click"], description: "Context menu (New/Delete)" },
],
},
{
@@ -0,0 +1,60 @@
<script lang="ts">
interface Props {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
destructive?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
let {
title,
message,
confirmText = "Confirm",
cancelText = "Cancel",
destructive = false,
onConfirm,
onCancel,
}: Props = $props();
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
onCancel();
} else if (event.key === "Enter") {
onConfirm();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onclick={onCancel}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 w-full max-w-md rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-xl"
onclick={(e) => e.stopPropagation()}
>
<h2 class="mb-2 text-lg font-semibold text-gray-100">{title}</h2>
<p class="mb-6 text-sm text-gray-300">{message}</p>
<div class="flex justify-end gap-3">
<button
class="rounded-md border border-gray-600 bg-gray-700 px-4 py-2 text-sm text-gray-200 hover:bg-gray-600"
onclick={onCancel}
>
{cancelText}
</button>
<button
class="rounded-md px-4 py-2 text-sm text-white {destructive
? 'bg-red-600 hover:bg-red-700'
: 'bg-pink-600 hover:bg-pink-700'}"
onclick={onConfirm}
>
{confirmText}
</button>
</div>
</div>
</div>
+203 -18
View File
@@ -1,36 +1,169 @@
<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" | 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 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();
}
function handleNewFile() {
openNewFileDialog($currentDirectory);
}
function handleNewFolder() {
openNewFolderDialog($currentDirectory);
}
</script>
<div class="file-browser">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="file-browser" oncontextmenu={(e) => handleContextMenu(e, null)}>
<div class="header">
<span class="title">Files</span>
<button class="refresh-button" on:click={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 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">
@@ -63,13 +196,60 @@
{:else}
<div class="tree">
{#each $fileTree as entry (entry.path)}
<FileTreeItem {entry} />
<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}
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}
<style>
.file-browser {
display: flex;
@@ -96,7 +276,12 @@
letter-spacing: 0.5px;
}
.refresh-button {
.header-buttons {
display: flex;
gap: 4px;
}
.header-button {
display: flex;
align-items: center;
justify-content: center;
@@ -109,7 +294,7 @@
transition: all 0.15s ease;
}
.refresh-button:hover {
.header-button:hover {
background-color: var(--bg-primary);
color: var(--text-primary);
}
@@ -0,0 +1,110 @@
<script lang="ts">
import type { FileEntry } from "$lib/types/editor";
interface Props {
x: number;
y: number;
targetEntry: FileEntry | null;
currentDirectory: string;
onNewFile: (parentPath: string) => void;
onNewFolder: (parentPath: string) => void;
onDelete: (entry: FileEntry) => void;
onClose: () => void;
}
let { x, y, targetEntry, currentDirectory, onNewFile, onNewFolder, onDelete, onClose }: Props =
$props();
function handleNewFile() {
const parentPath = targetEntry?.isDirectory ? targetEntry.path : currentDirectory;
onNewFile(parentPath);
onClose();
}
function handleNewFolder() {
const parentPath = targetEntry?.isDirectory ? targetEntry.path : currentDirectory;
onNewFolder(parentPath);
onClose();
}
function handleDelete() {
if (targetEntry) {
onDelete(targetEntry);
onClose();
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50"
onclick={onClose}
oncontextmenu={(e) => {
e.preventDefault();
onClose();
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute z-50 min-w-[160px] rounded-md border border-gray-700 bg-gray-800 py-1 shadow-lg"
style="left: {x}px; top: {y}px;"
onclick={(e) => e.stopPropagation()}
>
<button
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-gray-200 hover:bg-gray-700"
onclick={handleNewFile}
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
New File
</button>
<button
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-gray-200 hover:bg-gray-700"
onclick={handleNewFolder}
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
/>
</svg>
New Folder
</button>
{#if targetEntry}
<div class="my-1 border-t border-gray-700"></div>
<button
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-red-400 hover:bg-gray-700"
onclick={handleDelete}
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
Delete {targetEntry.isDirectory ? "Folder" : "File"}
</button>
{/if}
</div>
</div>
+21 -8
View File
@@ -1,9 +1,15 @@
<script lang="ts">
import type { FileEntry } from "$lib/types/editor";
import { editorStore } from "$lib/stores/editor";
import Self from "./FileTreeItem.svelte";
export let entry: FileEntry;
export let depth: number = 0;
interface Props {
entry: FileEntry;
depth?: number;
onContextMenu?: (event: MouseEvent, entry: FileEntry) => void;
}
let { entry, depth = 0, onContextMenu }: Props = $props();
function handleClick() {
if (entry.isDirectory) {
@@ -20,9 +26,15 @@
}
}
$: isExpanded = entry.isExpanded ?? false;
$: isLoading = entry.isLoading ?? false;
$: children = entry.children ?? [];
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">
@@ -31,8 +43,9 @@
class:directory={entry.isDirectory}
class:file={!entry.isDirectory}
style="padding-left: {depth * 16 + 8}px"
on:click={handleClick}
on:keydown={handleKeydown}
onclick={handleClick}
onkeydown={handleKeydown}
oncontextmenu={handleContextMenu}
title={entry.path}
>
{#if entry.isDirectory}
@@ -109,7 +122,7 @@
{#if entry.isDirectory && isExpanded && children.length > 0}
<div class="children">
{#each children as child (child.path)}
<svelte:self entry={child} depth={depth + 1} />
<Self entry={child} depth={depth + 1} {onContextMenu} />
{/each}
</div>
{/if}
@@ -0,0 +1,80 @@
<script lang="ts">
interface Props {
title: string;
placeholder?: string;
confirmText?: string;
cancelText?: string;
onConfirm: (value: string) => void;
onCancel: () => void;
}
let {
title,
placeholder = "",
confirmText = "Create",
cancelText = "Cancel",
onConfirm,
onCancel,
}: Props = $props();
let inputValue = $state("");
let inputElement: HTMLInputElement | undefined = $state();
$effect(() => {
if (inputElement) {
inputElement.focus();
}
});
function handleSubmit() {
const trimmed = inputValue.trim();
if (trimmed) {
onConfirm(trimmed);
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
onCancel();
} else if (event.key === "Enter") {
handleSubmit();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onclick={onCancel}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="mx-4 w-full max-w-md rounded-lg border border-gray-700 bg-gray-800 p-6 shadow-xl"
onclick={(e) => e.stopPropagation()}
>
<h2 class="mb-4 text-lg font-semibold text-gray-100">{title}</h2>
<input
bind:this={inputElement}
bind:value={inputValue}
type="text"
{placeholder}
class="mb-6 w-full rounded-md border border-gray-600 bg-gray-700 px-3 py-2 text-sm text-gray-100 placeholder-gray-400 focus:border-pink-500 focus:outline-none focus:ring-1 focus:ring-pink-500"
/>
<div class="flex justify-end gap-3">
<button
class="rounded-md border border-gray-600 bg-gray-700 px-4 py-2 text-sm text-gray-200 hover:bg-gray-600"
onclick={onCancel}
>
{cancelText}
</button>
<button
class="rounded-md bg-pink-600 px-4 py-2 text-sm text-white hover:bg-pink-700 disabled:cursor-not-allowed disabled:opacity-50"
onclick={handleSubmit}
disabled={!inputValue.trim()}
>
{confirmText}
</button>
</div>
</div>
</div>
+99
View File
@@ -246,6 +246,100 @@ function createEditorStore() {
isEditorVisible.update((v) => !v);
}
async function createFile(parentPath: string, fileName: string): Promise<boolean> {
const filePath = `${parentPath}/${fileName}`;
try {
await invoke("create_file", { path: filePath });
// Refresh the parent directory
await refreshDirectory(parentPath);
return true;
} catch (error) {
console.error("Failed to create file:", error);
saveError.set(`Failed to create file: ${error}`);
return false;
}
}
async function createDirectory(parentPath: string, dirName: string): Promise<boolean> {
const dirPath = `${parentPath}/${dirName}`;
try {
await invoke("create_directory", { path: dirPath });
// Refresh the parent directory
await refreshDirectory(parentPath);
return true;
} catch (error) {
console.error("Failed to create directory:", error);
saveError.set(`Failed to create directory: ${error}`);
return false;
}
}
async function deleteFile(filePath: string): Promise<boolean> {
try {
await invoke("delete_file", { path: filePath });
// Close the tab if it's open
const currentState = get(state);
const openTab = currentState.tabs.find((t) => t.filePath === filePath);
if (openTab) {
closeTab(openTab.id);
}
// Refresh the parent directory
const parentPath = filePath.substring(0, filePath.lastIndexOf("/"));
await refreshDirectory(parentPath);
return true;
} catch (error) {
console.error("Failed to delete file:", error);
saveError.set(`Failed to delete file: ${error}`);
return false;
}
}
async function deleteDirectory(dirPath: string): Promise<boolean> {
try {
await invoke("delete_directory", { path: dirPath });
// Close any tabs that are in this directory
const currentState = get(state);
const tabsToClose = currentState.tabs.filter((t) => t.filePath.startsWith(dirPath + "/"));
tabsToClose.forEach((tab) => closeTab(tab.id));
// Refresh the parent directory
const parentPath = dirPath.substring(0, dirPath.lastIndexOf("/"));
await refreshDirectory(parentPath);
return true;
} catch (error) {
console.error("Failed to delete directory:", error);
saveError.set(`Failed to delete directory: ${error}`);
return false;
}
}
async function refreshDirectory(dirPath: string) {
const currentState = get(state);
// If refreshing the root directory, reload the entire tree
if (dirPath === currentState.currentDirectory) {
const entries = await loadDirectory(dirPath);
state.update((s) => ({ ...s, fileTree: entries }));
return;
}
// Otherwise, update the specific directory in the tree
const children = await loadDirectory(dirPath);
state.update((s) => {
const updateTree = (entries: FileEntry[]): FileEntry[] => {
return entries.map((e) => {
if (e.path === dirPath) {
return { ...e, children, isExpanded: true };
}
if (e.children) {
return { ...e, children: updateTree(e.children) };
}
return e;
});
};
return { ...s, fileTree: updateTree(s.fileTree) };
});
}
function showEditor() {
isEditorVisible.set(true);
}
@@ -270,6 +364,11 @@ function createEditorStore() {
toggleEditor,
showEditor,
hideEditor,
createFile,
createDirectory,
deleteFile,
deleteDirectory,
refreshDirectory,
tabs: derived(state, ($state) => $state.tabs),
activeTab: derived(state, ($state) => $state.tabs.find((t) => t.id === $state.activeTabId)),
+9
View File
@@ -238,6 +238,15 @@
}
return;
}
// Ctrl+N - New file (when editor is visible)
// Note: This just emits an event that FileBrowser listens to
if (event.ctrlKey && event.key === "n" && get(editorStore.isEditorVisible)) {
event.preventDefault();
// Dispatch a custom event that FileBrowser will listen to
window.dispatchEvent(new CustomEvent("editor-new-file"));
return;
}
}
async function handleInterrupt() {