Files
hikari-desktop/src/lib/components/editor/FileContextMenu.svelte
T
hikari e45a1a1c98
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m4s
CI / Build Linux (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
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>
2026-01-28 18:20:02 -08:00

204 lines
5.0 KiB
Svelte

<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;
onRename: (entry: FileEntry) => void;
onDelete: (entry: FileEntry) => void;
onClose: () => void;
}
let {
x,
y,
targetEntry,
currentDirectory,
onNewFile,
onNewFolder,
onRename,
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 handleRename() {
if (targetEntry) {
onRename(targetEntry);
onClose();
}
}
function handleDelete() {
if (targetEntry) {
onDelete(targetEntry);
onClose();
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
// Menu element reference for measuring
let menuElement: HTMLDivElement | undefined = $state();
// Adjusted position to keep menu within viewport
let adjustedX = $derived.by(() => {
if (!menuElement) return x;
const menuWidth = menuElement.offsetWidth || 160;
const maxX = window.innerWidth - menuWidth - 8;
return Math.min(x, maxX);
});
let adjustedY = $derived.by(() => {
if (!menuElement) return y;
const menuHeight = menuElement.offsetHeight || 200;
const maxY = window.innerHeight - menuHeight - 8;
return Math.min(y, maxY);
});
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="menu-overlay"
onclick={onClose}
oncontextmenu={(e) => {
e.preventDefault();
onClose();
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={menuElement}
class="menu-content"
style="left: {adjustedX}px; top: {adjustedY}px;"
onclick={(e) => e.stopPropagation()}
>
<button class="menu-item" onclick={handleNewFile}>
<svg class="menu-icon" 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="menu-item" onclick={handleNewFolder}>
<svg class="menu-icon" 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="menu-divider"></div>
<button class="menu-item" onclick={handleRename}>
<svg class="menu-icon" 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>
Rename
</button>
<button class="menu-item destructive" onclick={handleDelete}>
<svg class="menu-icon" 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>
<style>
.menu-overlay {
position: fixed;
inset: 0;
z-index: 50;
}
.menu-content {
position: absolute;
z-index: 50;
min-width: 160px;
border-radius: 0.375rem;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
padding: 0.25rem 0;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.menu-item {
display: flex;
width: 100%;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
text-align: left;
font-size: 0.875rem;
color: var(--text-primary);
background: transparent;
border: none;
cursor: pointer;
transition: background-color 0.15s ease;
}
.menu-item:hover {
background-color: var(--bg-primary);
}
.menu-item.destructive {
color: #ef4444;
}
.menu-divider {
margin: 0.25rem 0;
border-top: 1px solid var(--border-color);
}
.menu-icon {
width: 1rem;
height: 1rem;
}
</style>