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,426 @@
|
||||
import { writable, derived, get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { EditorState, EditorTab, FileEntry } from "$lib/types/editor";
|
||||
|
||||
const defaultState: EditorState = {
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
isFileBrowserOpen: true,
|
||||
currentDirectory: "",
|
||||
fileTree: [],
|
||||
isLoadingTree: false,
|
||||
};
|
||||
|
||||
function getLanguageFromPath(filePath: string): string {
|
||||
const ext = filePath.split(".").pop()?.toLowerCase() || "";
|
||||
const languageMap: Record<string, string> = {
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
py: "python",
|
||||
rs: "rust",
|
||||
go: "go",
|
||||
java: "java",
|
||||
c: "c",
|
||||
cpp: "cpp",
|
||||
h: "c",
|
||||
hpp: "cpp",
|
||||
cs: "csharp",
|
||||
rb: "ruby",
|
||||
php: "php",
|
||||
swift: "swift",
|
||||
kt: "kotlin",
|
||||
scala: "scala",
|
||||
html: "html",
|
||||
htm: "html",
|
||||
css: "css",
|
||||
scss: "scss",
|
||||
sass: "sass",
|
||||
less: "less",
|
||||
json: "json",
|
||||
xml: "xml",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
toml: "toml",
|
||||
md: "markdown",
|
||||
markdown: "markdown",
|
||||
sql: "sql",
|
||||
sh: "shell",
|
||||
bash: "shell",
|
||||
zsh: "shell",
|
||||
ps1: "powershell",
|
||||
dockerfile: "dockerfile",
|
||||
svelte: "svelte",
|
||||
vue: "vue",
|
||||
graphql: "graphql",
|
||||
gql: "graphql",
|
||||
lua: "lua",
|
||||
r: "r",
|
||||
dart: "dart",
|
||||
elm: "elm",
|
||||
ex: "elixir",
|
||||
exs: "elixir",
|
||||
erl: "erlang",
|
||||
hs: "haskell",
|
||||
clj: "clojure",
|
||||
lisp: "lisp",
|
||||
ml: "ocaml",
|
||||
fs: "fsharp",
|
||||
zig: "zig",
|
||||
nim: "nim",
|
||||
v: "v",
|
||||
wasm: "wasm",
|
||||
wat: "wasm",
|
||||
};
|
||||
return languageMap[ext] || "plaintext";
|
||||
}
|
||||
|
||||
function generateTabId(): string {
|
||||
return `tab-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
function createEditorStore() {
|
||||
const state = writable<EditorState>(defaultState);
|
||||
const isEditorVisible = writable<boolean>(false);
|
||||
const saveError = writable<string | null>(null);
|
||||
|
||||
async function loadDirectory(dirPath: string): Promise<FileEntry[]> {
|
||||
try {
|
||||
const entries = await invoke<FileEntry[]>("list_directory", { path: dirPath });
|
||||
return entries.sort((a, b) => {
|
||||
if (a.isDirectory && !b.isDirectory) return -1;
|
||||
if (!a.isDirectory && b.isDirectory) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to load directory:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeFileTree(rootPath: string) {
|
||||
state.update((s) => ({ ...s, isLoadingTree: true, currentDirectory: rootPath }));
|
||||
try {
|
||||
const entries = await loadDirectory(rootPath);
|
||||
state.update((s) => ({ ...s, fileTree: entries, isLoadingTree: false }));
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize file tree:", error);
|
||||
state.update((s) => ({ ...s, isLoadingTree: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDirectory(entry: FileEntry) {
|
||||
if (!entry.isDirectory) return;
|
||||
|
||||
state.update((s) => {
|
||||
const updateTree = (entries: FileEntry[]): FileEntry[] => {
|
||||
return entries.map((e) => {
|
||||
if (e.path === entry.path) {
|
||||
return { ...e, isExpanded: !e.isExpanded, isLoading: !e.isExpanded && !e.children };
|
||||
}
|
||||
if (e.children) {
|
||||
return { ...e, children: updateTree(e.children) };
|
||||
}
|
||||
return e;
|
||||
});
|
||||
};
|
||||
return { ...s, fileTree: updateTree(s.fileTree) };
|
||||
});
|
||||
|
||||
if (!entry.isExpanded && !entry.children) {
|
||||
const children = await loadDirectory(entry.path);
|
||||
state.update((s) => {
|
||||
const updateTree = (entries: FileEntry[]): FileEntry[] => {
|
||||
return entries.map((e) => {
|
||||
if (e.path === entry.path) {
|
||||
return { ...e, children, isLoading: false };
|
||||
}
|
||||
if (e.children) {
|
||||
return { ...e, children: updateTree(e.children) };
|
||||
}
|
||||
return e;
|
||||
});
|
||||
};
|
||||
return { ...s, fileTree: updateTree(s.fileTree) };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function openFile(filePath: string) {
|
||||
const currentState = get(state);
|
||||
|
||||
const existingTab = currentState.tabs.find((t) => t.filePath === filePath);
|
||||
if (existingTab) {
|
||||
state.update((s) => ({ ...s, activeTabId: existingTab.id }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await invoke<string>("read_file_content", { path: filePath });
|
||||
const fileName = filePath.split(/[/\\]/).pop() || "untitled";
|
||||
const language = getLanguageFromPath(filePath);
|
||||
const newTab: EditorTab = {
|
||||
id: generateTabId(),
|
||||
filePath,
|
||||
fileName,
|
||||
content,
|
||||
originalContent: content,
|
||||
isDirty: false,
|
||||
language,
|
||||
};
|
||||
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
tabs: [...s.tabs, newTab],
|
||||
activeTabId: newTab.id,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to open file:", error);
|
||||
saveError.set(`Failed to open file: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile(tabId?: string) {
|
||||
const currentState = get(state);
|
||||
const tab = tabId
|
||||
? currentState.tabs.find((t) => t.id === tabId)
|
||||
: currentState.tabs.find((t) => t.id === currentState.activeTabId);
|
||||
|
||||
if (!tab) return;
|
||||
|
||||
saveError.set(null);
|
||||
try {
|
||||
await invoke("write_file_content", { path: tab.filePath, content: tab.content });
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === tab.id ? { ...t, originalContent: t.content, isDirty: false } : t
|
||||
),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Failed to save file:", error);
|
||||
saveError.set(`Failed to save file: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function updateTabContent(tabId: string, content: string) {
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
tabs: s.tabs.map((t) =>
|
||||
t.id === tabId ? { ...t, content, isDirty: content !== t.originalContent } : t
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function closeTab(tabId: string) {
|
||||
state.update((s) => {
|
||||
const tabIndex = s.tabs.findIndex((t) => t.id === tabId);
|
||||
const newTabs = s.tabs.filter((t) => t.id !== tabId);
|
||||
|
||||
let newActiveId = s.activeTabId;
|
||||
if (s.activeTabId === tabId) {
|
||||
if (newTabs.length === 0) {
|
||||
newActiveId = null;
|
||||
} else if (tabIndex >= newTabs.length) {
|
||||
newActiveId = newTabs[newTabs.length - 1].id;
|
||||
} else {
|
||||
newActiveId = newTabs[tabIndex].id;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...s, tabs: newTabs, activeTabId: newActiveId };
|
||||
});
|
||||
}
|
||||
|
||||
function setActiveTab(tabId: string) {
|
||||
state.update((s) => ({ ...s, activeTabId: tabId }));
|
||||
}
|
||||
|
||||
function toggleFileBrowser() {
|
||||
state.update((s) => ({ ...s, isFileBrowserOpen: !s.isFileBrowserOpen }));
|
||||
}
|
||||
|
||||
function toggleEditor() {
|
||||
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) };
|
||||
});
|
||||
}
|
||||
|
||||
async function renamePath(oldPath: string, newName: string): Promise<boolean> {
|
||||
const parentPath = oldPath.substring(0, oldPath.lastIndexOf("/"));
|
||||
const newPath = `${parentPath}/${newName}`;
|
||||
|
||||
try {
|
||||
await invoke("rename_path", { oldPath, newPath });
|
||||
|
||||
// Update any open tabs that reference this path
|
||||
state.update((s) => ({
|
||||
...s,
|
||||
tabs: s.tabs.map((t) => {
|
||||
if (t.filePath === oldPath) {
|
||||
// Exact match - this file was renamed
|
||||
return {
|
||||
...t,
|
||||
filePath: newPath,
|
||||
fileName: newName,
|
||||
};
|
||||
}
|
||||
if (t.filePath.startsWith(oldPath + "/")) {
|
||||
// File is inside a renamed directory
|
||||
const relativePath = t.filePath.substring(oldPath.length);
|
||||
return {
|
||||
...t,
|
||||
filePath: newPath + relativePath,
|
||||
};
|
||||
}
|
||||
return t;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Refresh the parent directory
|
||||
await refreshDirectory(parentPath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to rename:", error);
|
||||
saveError.set(`Failed to rename: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function showEditor() {
|
||||
isEditorVisible.set(true);
|
||||
}
|
||||
|
||||
function hideEditor() {
|
||||
isEditorVisible.set(false);
|
||||
}
|
||||
|
||||
return {
|
||||
state: { subscribe: state.subscribe },
|
||||
isEditorVisible: { subscribe: isEditorVisible.subscribe },
|
||||
saveError: { subscribe: saveError.subscribe },
|
||||
|
||||
initializeFileTree,
|
||||
toggleDirectory,
|
||||
openFile,
|
||||
saveFile,
|
||||
updateTabContent,
|
||||
closeTab,
|
||||
setActiveTab,
|
||||
toggleFileBrowser,
|
||||
toggleEditor,
|
||||
showEditor,
|
||||
hideEditor,
|
||||
createFile,
|
||||
createDirectory,
|
||||
deleteFile,
|
||||
deleteDirectory,
|
||||
refreshDirectory,
|
||||
renamePath,
|
||||
|
||||
tabs: derived(state, ($state) => $state.tabs),
|
||||
activeTab: derived(state, ($state) => $state.tabs.find((t) => t.id === $state.activeTabId)),
|
||||
activeTabId: derived(state, ($state) => $state.activeTabId),
|
||||
fileTree: derived(state, ($state) => $state.fileTree),
|
||||
isFileBrowserOpen: derived(state, ($state) => $state.isFileBrowserOpen),
|
||||
isLoadingTree: derived(state, ($state) => $state.isLoadingTree),
|
||||
currentDirectory: derived(state, ($state) => $state.currentDirectory),
|
||||
hasDirtyTabs: derived(state, ($state) => $state.tabs.some((t) => t.isDirty)),
|
||||
};
|
||||
}
|
||||
|
||||
export const editorStore = createEditorStore();
|
||||
Reference in New Issue
Block a user