generated from nhcarrigan/template
feat: add built-in file editor with syntax highlighting
- Add CodeMirror 6 editor with support for 40+ languages - Add file browser sidebar with directory tree navigation - Add multi-tab support with dirty state indicators - Add keyboard shortcuts (Ctrl+E, Ctrl+B, Ctrl+S, Ctrl+W) - Add editor toggle button to status bar - Editor uses current session's CWD and requires connection - Add Tauri commands for file operations (list, read, write)
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
{ keys: ["Escape"], description: "Close modals and panels" },
|
||||
{ keys: ["Ctrl", "L"], description: "Clear the terminal" },
|
||||
{ keys: ["Ctrl", ","], description: "Open settings" },
|
||||
{ keys: ["Ctrl", "E"], description: "Toggle file editor" },
|
||||
{ keys: ["Ctrl", "Shift", "M"], description: "Toggle compact mode" },
|
||||
{ keys: ["Ctrl", "Shift", "S"], description: "Toggle streamer mode" },
|
||||
],
|
||||
@@ -26,6 +27,15 @@
|
||||
{ keys: ["↓"], description: "Next input from history" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "File Editor",
|
||||
items: [
|
||||
{ keys: ["Ctrl", "E"], description: "Toggle editor view" },
|
||||
{ keys: ["Ctrl", "B"], description: "Toggle file browser" },
|
||||
{ keys: ["Ctrl", "S"], description: "Save current file" },
|
||||
{ keys: ["Ctrl", "W"], description: "Close current tab" },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Slash Commands",
|
||||
items: [
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { get } from "svelte/store";
|
||||
import { claudeStore } from "$lib/stores/claude";
|
||||
import { configStore, type HikariConfig, isStreamerMode } from "$lib/stores/config";
|
||||
import { editorStore } from "$lib/stores/editor";
|
||||
import type { ConnectionStatus } from "$lib/types/messages";
|
||||
import { onMount } from "svelte";
|
||||
import StatsDisplay from "./StatsDisplay.svelte";
|
||||
@@ -80,6 +81,15 @@
|
||||
streamerModeActive = value;
|
||||
});
|
||||
|
||||
let editorVisible = $state(false);
|
||||
editorStore.isEditorVisible.subscribe((value) => {
|
||||
editorVisible = value;
|
||||
});
|
||||
|
||||
function toggleEditor() {
|
||||
editorStore.toggleEditor();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
appVersion = await getVersion();
|
||||
});
|
||||
@@ -307,6 +317,25 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={toggleEditor}
|
||||
disabled={connectionStatus !== "connected"}
|
||||
class="p-1 text-gray-500 icon-trans-hover {editorVisible
|
||||
? 'text-[var(--trans-pink)]'
|
||||
: ''} disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
title={connectionStatus === "connected"
|
||||
? "File Editor (Ctrl+E)"
|
||||
: "Connect to enable file editor"}
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showStats = !showStats)}
|
||||
class="p-1 text-gray-500 icon-trans-hover {showStats ? 'text-[var(--trans-pink)]' : ''}"
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { EditorView, basicSetup } from "codemirror";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { keymap } from "@codemirror/view";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { python } from "@codemirror/lang-python";
|
||||
import { rust } from "@codemirror/lang-rust";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { css } from "@codemirror/lang-css";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { markdown } from "@codemirror/lang-markdown";
|
||||
import { xml } from "@codemirror/lang-xml";
|
||||
import { sql } from "@codemirror/lang-sql";
|
||||
import { java } from "@codemirror/lang-java";
|
||||
import { cpp } from "@codemirror/lang-cpp";
|
||||
import { php } from "@codemirror/lang-php";
|
||||
import { go } from "@codemirror/lang-go";
|
||||
import { yaml } from "@codemirror/lang-yaml";
|
||||
import { sass } from "@codemirror/lang-sass";
|
||||
import { less } from "@codemirror/lang-less";
|
||||
import { vue } from "@codemirror/lang-vue";
|
||||
import { angular } from "@codemirror/lang-angular";
|
||||
import { wast } from "@codemirror/lang-wast";
|
||||
import { StreamLanguage } from "@codemirror/language";
|
||||
import { shell } from "@codemirror/legacy-modes/mode/shell";
|
||||
import { ruby } from "@codemirror/legacy-modes/mode/ruby";
|
||||
import { swift } from "@codemirror/legacy-modes/mode/swift";
|
||||
import { lua } from "@codemirror/legacy-modes/mode/lua";
|
||||
import { r } from "@codemirror/legacy-modes/mode/r";
|
||||
import { toml } from "@codemirror/legacy-modes/mode/toml";
|
||||
import { dockerFile } from "@codemirror/legacy-modes/mode/dockerfile";
|
||||
import { powerShell } from "@codemirror/legacy-modes/mode/powershell";
|
||||
import { editorStore } from "$lib/stores/editor";
|
||||
import type { EditorTab } from "$lib/types/editor";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
|
||||
export let tab: EditorTab;
|
||||
|
||||
let editorContainer: HTMLDivElement;
|
||||
let view: EditorView | null = null;
|
||||
|
||||
function getLanguageExtension(language: string): Extension {
|
||||
const languageMap: Record<string, () => Extension> = {
|
||||
javascript: () => javascript({ jsx: true, typescript: false }),
|
||||
typescript: () => javascript({ jsx: true, typescript: true }),
|
||||
python: () => python(),
|
||||
rust: () => rust(),
|
||||
html: () => html(),
|
||||
css: () => css(),
|
||||
json: () => json(),
|
||||
markdown: () => markdown(),
|
||||
xml: () => xml(),
|
||||
sql: () => sql(),
|
||||
java: () => java(),
|
||||
c: () => cpp(),
|
||||
cpp: () => cpp(),
|
||||
csharp: () => cpp(),
|
||||
php: () => php(),
|
||||
go: () => go(),
|
||||
yaml: () => yaml(),
|
||||
scss: () => sass(),
|
||||
sass: () => sass(),
|
||||
less: () => less(),
|
||||
vue: () => vue(),
|
||||
angular: () => angular(),
|
||||
wasm: () => wast(),
|
||||
shell: () => StreamLanguage.define(shell),
|
||||
ruby: () => StreamLanguage.define(ruby),
|
||||
swift: () => StreamLanguage.define(swift),
|
||||
lua: () => StreamLanguage.define(lua),
|
||||
r: () => StreamLanguage.define(r),
|
||||
toml: () => StreamLanguage.define(toml),
|
||||
dockerfile: () => StreamLanguage.define(dockerFile),
|
||||
powershell: () => StreamLanguage.define(powerShell),
|
||||
svelte: () => html(),
|
||||
};
|
||||
|
||||
const getExtension = languageMap[language];
|
||||
return getExtension ? getExtension() : [];
|
||||
}
|
||||
|
||||
function createEditor() {
|
||||
if (!editorContainer) return;
|
||||
|
||||
const saveKeymap = keymap.of([
|
||||
{
|
||||
key: "Mod-s",
|
||||
run: () => {
|
||||
editorStore.saveFile(tab.id);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const updateListener = EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
const content = update.state.doc.toString();
|
||||
editorStore.updateTabContent(tab.id, content);
|
||||
}
|
||||
});
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: tab.content,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
oneDark,
|
||||
getLanguageExtension(tab.language),
|
||||
saveKeymap,
|
||||
updateListener,
|
||||
EditorView.theme({
|
||||
"&": {
|
||||
height: "100%",
|
||||
fontSize: "14px",
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
},
|
||||
".cm-content": {
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "var(--bg-secondary)",
|
||||
borderRight: "1px solid var(--border-color)",
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
view = new EditorView({
|
||||
state,
|
||||
parent: editorContainer,
|
||||
});
|
||||
}
|
||||
|
||||
function destroyEditor() {
|
||||
if (view) {
|
||||
view.destroy();
|
||||
view = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
createEditor();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
destroyEditor();
|
||||
});
|
||||
|
||||
$: if (view && tab.content !== view.state.doc.toString()) {
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.state.doc.length,
|
||||
insert: tab.content,
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="code-editor" bind:this={editorContainer}></div>
|
||||
|
||||
<style>
|
||||
.code-editor {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-terminal);
|
||||
}
|
||||
|
||||
.code-editor :global(.cm-editor) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.code-editor :global(.cm-focused) {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,253 @@
|
||||
<script lang="ts">
|
||||
import { editorStore } from "$lib/stores/editor";
|
||||
import FileBrowser from "./FileBrowser.svelte";
|
||||
import EditorTabs from "./EditorTabs.svelte";
|
||||
import CodeEditor from "./CodeEditor.svelte";
|
||||
|
||||
const isFileBrowserOpen = editorStore.isFileBrowserOpen;
|
||||
const activeTab = editorStore.activeTab;
|
||||
const saveError = editorStore.saveError;
|
||||
|
||||
function toggleFileBrowser() {
|
||||
editorStore.toggleFileBrowser();
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await editorStore.saveFile();
|
||||
} catch {
|
||||
// Error is already set in the store
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="editor-panel">
|
||||
<div class="toolbar">
|
||||
<button
|
||||
class="toolbar-button"
|
||||
class:active={$isFileBrowserOpen}
|
||||
on:click={toggleFileBrowser}
|
||||
title="Toggle file browser (Ctrl+B)"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
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" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="toolbar-spacer"></div>
|
||||
|
||||
{#if $activeTab}
|
||||
<button class="toolbar-button" on:click={handleSave} title="Save (Ctrl+S)">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||
<polyline points="17 21 17 13 7 13 7 21" />
|
||||
<polyline points="7 3 7 8 15 8" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if $saveError}
|
||||
<div class="error-banner">
|
||||
<span>{$saveError}</span>
|
||||
<button
|
||||
class="dismiss-button"
|
||||
on:click={() => {}}
|
||||
title="Dismiss error"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="editor-content">
|
||||
{#if $isFileBrowserOpen}
|
||||
<div class="file-browser-container">
|
||||
<FileBrowser />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="editor-main">
|
||||
<EditorTabs />
|
||||
|
||||
<div class="editor-area">
|
||||
{#if $activeTab}
|
||||
{#key $activeTab.id}
|
||||
<CodeEditor tab={$activeTab} />
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="no-file">
|
||||
<div class="no-file-content">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
>
|
||||
<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>
|
||||
<p>Select a file to edit</p>
|
||||
<p class="hint">Use the file browser on the left or press Ctrl+B to toggle it</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.editor-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.toolbar-button:hover {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar-button.active {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.toolbar-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background-color: #ff000022;
|
||||
border-bottom: 1px solid #ff0000;
|
||||
color: #ff6b6b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dismiss-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #ff6b6b;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dismiss-button:hover {
|
||||
background-color: #ff000033;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-browser-container {
|
||||
width: 250px;
|
||||
min-width: 150px;
|
||||
max-width: 400px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-area {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.no-file {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--bg-terminal);
|
||||
}
|
||||
|
||||
.no-file-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-file-content svg {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.no-file-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.no-file-content .hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import { editorStore } from "$lib/stores/editor";
|
||||
|
||||
const tabs = editorStore.tabs;
|
||||
const activeTabId = editorStore.activeTabId;
|
||||
|
||||
function handleTabClick(tabId: string) {
|
||||
editorStore.setActiveTab(tabId);
|
||||
}
|
||||
|
||||
function handleCloseTab(event: MouseEvent, tabId: string) {
|
||||
event.stopPropagation();
|
||||
editorStore.closeTab(tabId);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent, tabId: string) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
editorStore.setActiveTab(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCloseKeydown(event: KeyboardEvent, tabId: string) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
editorStore.closeTab(tabId);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="editor-tabs">
|
||||
{#if $tabs.length === 0}
|
||||
<div class="no-tabs">No files open</div>
|
||||
{:else}
|
||||
<div class="tabs-container" role="tablist">
|
||||
{#each $tabs as tab (tab.id)}
|
||||
<div
|
||||
class="tab"
|
||||
class:active={tab.id === $activeTabId}
|
||||
class:dirty={tab.isDirty}
|
||||
role="tab"
|
||||
tabindex="0"
|
||||
aria-selected={tab.id === $activeTabId}
|
||||
on:click={() => handleTabClick(tab.id)}
|
||||
on:keydown={(e) => handleKeydown(e, tab.id)}
|
||||
title={tab.filePath}
|
||||
>
|
||||
<span class="tab-name">
|
||||
{tab.fileName}
|
||||
{#if tab.isDirty}
|
||||
<span class="dirty-indicator">*</span>
|
||||
{/if}
|
||||
</span>
|
||||
<button
|
||||
class="close-button"
|
||||
on:click={(e) => handleCloseTab(e, tab.id)}
|
||||
on:keydown={(e) => handleCloseKeydown(e, tab.id)}
|
||||
title="Close tab"
|
||||
aria-label="Close {tab.fileName}"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.editor-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.no-tabs {
|
||||
padding: 8px 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab:focus {
|
||||
outline: 1px solid var(--accent-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background-color: var(--bg-terminal);
|
||||
color: var(--text-primary);
|
||||
border-bottom: 2px solid var(--accent-primary);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dirty-indicator {
|
||||
color: var(--accent-primary);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tab:hover .close-button,
|
||||
.tab.active .close-button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tab.dirty .close-button {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import { editorStore } from "$lib/stores/editor";
|
||||
import FileTreeItem from "./FileTreeItem.svelte";
|
||||
|
||||
const fileTree = editorStore.fileTree;
|
||||
const isLoadingTree = editorStore.isLoadingTree;
|
||||
const currentDirectory = editorStore.currentDirectory;
|
||||
|
||||
function handleRefresh() {
|
||||
const dir = $currentDirectory;
|
||||
if (dir) {
|
||||
editorStore.initializeFileTree(dir);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="file-browser">
|
||||
<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>
|
||||
|
||||
<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} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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;
|
||||
}
|
||||
|
||||
.refresh-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;
|
||||
}
|
||||
|
||||
.refresh-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>
|
||||
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import type { FileEntry } from "$lib/types/editor";
|
||||
import { editorStore } from "$lib/stores/editor";
|
||||
|
||||
export let entry: FileEntry;
|
||||
export let depth: number = 0;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
$: isExpanded = entry.isExpanded ?? false;
|
||||
$: isLoading = entry.isLoading ?? false;
|
||||
$: children = 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"
|
||||
on:click={handleClick}
|
||||
on:keydown={handleKeydown}
|
||||
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)}
|
||||
<svelte:self entry={child} depth={depth + 1} />
|
||||
{/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>
|
||||
@@ -0,0 +1,285 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
isExpanded?: boolean;
|
||||
children?: FileEntry[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface EditorTab {
|
||||
id: string;
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
content: string;
|
||||
originalContent: string;
|
||||
isDirty: boolean;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export interface EditorState {
|
||||
tabs: EditorTab[];
|
||||
activeTabId: string | null;
|
||||
isFileBrowserOpen: boolean;
|
||||
currentDirectory: string;
|
||||
fileTree: FileEntry[];
|
||||
isLoadingTree: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user