generated from nhcarrigan/template
feat: add create and delete file/folder functionality to editor
- Add Tauri backend commands: create_file, create_directory, delete_file, delete_directory - Add editor store functions for create/delete with auto-refresh - Add FileContextMenu component with right-click support - Add InputDialog component for file/folder name input - Add ConfirmDialog component for delete confirmation - Add Ctrl+N keyboard shortcut for new file - Update keyboard shortcuts modal with new shortcuts - Auto-close tabs when their files are deleted - Auto-refresh file tree after create/delete operations
This commit is contained in:
@@ -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))
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -154,6 +154,10 @@ pub fn run() {
|
|||||||
list_directory,
|
list_directory,
|
||||||
read_file_content,
|
read_file_content,
|
||||||
write_file_content,
|
write_file_content,
|
||||||
|
create_file,
|
||||||
|
create_directory,
|
||||||
|
delete_file,
|
||||||
|
delete_directory,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -34,6 +34,8 @@
|
|||||||
{ keys: ["Ctrl", "B"], description: "Toggle file browser" },
|
{ keys: ["Ctrl", "B"], description: "Toggle file browser" },
|
||||||
{ keys: ["Ctrl", "S"], description: "Save current file" },
|
{ keys: ["Ctrl", "S"], description: "Save current file" },
|
||||||
{ keys: ["Ctrl", "W"], description: "Close current tab" },
|
{ 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>
|
||||||
@@ -1,36 +1,169 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { editorStore } from "$lib/stores/editor";
|
import { editorStore } from "$lib/stores/editor";
|
||||||
import FileTreeItem from "./FileTreeItem.svelte";
|
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 fileTree = editorStore.fileTree;
|
||||||
const isLoadingTree = editorStore.isLoadingTree;
|
const isLoadingTree = editorStore.isLoadingTree;
|
||||||
const currentDirectory = editorStore.currentDirectory;
|
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() {
|
function handleRefresh() {
|
||||||
const dir = $currentDirectory;
|
const dir = $currentDirectory;
|
||||||
if (dir) {
|
if (dir) {
|
||||||
editorStore.initializeFileTree(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>
|
</script>
|
||||||
|
|
||||||
<div class="file-browser">
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="file-browser" oncontextmenu={(e) => handleContextMenu(e, null)}>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<span class="title">Files</span>
|
<span class="title">Files</span>
|
||||||
<button class="refresh-button" on:click={handleRefresh} title="Refresh file tree">
|
<div class="header-buttons">
|
||||||
<svg
|
<button class="header-button" onclick={handleNewFile} title="New File (Ctrl+N)">
|
||||||
width="14"
|
<svg
|
||||||
height="14"
|
width="14"
|
||||||
viewBox="0 0 24 24"
|
height="14"
|
||||||
fill="none"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
stroke-width="2"
|
stroke="currentColor"
|
||||||
>
|
stroke-width="2"
|
||||||
<path d="M23 4v6h-6" />
|
>
|
||||||
<path d="M1 20v-6h6" />
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
<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" />
|
<path d="M14 2v6h6" />
|
||||||
</svg>
|
<path d="M12 18v-6" />
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
<div class="tree-container">
|
<div class="tree-container">
|
||||||
@@ -63,13 +196,60 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="tree">
|
<div class="tree">
|
||||||
{#each $fileTree as entry (entry.path)}
|
{#each $fileTree as entry (entry.path)}
|
||||||
<FileTreeItem {entry} />
|
<FileTreeItem {entry} onContextMenu={handleContextMenu} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<style>
|
||||||
.file-browser {
|
.file-browser {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -96,7 +276,12 @@
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-button {
|
.header-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -109,7 +294,7 @@
|
|||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-button:hover {
|
.header-button:hover {
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
color: var(--text-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>
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { FileEntry } from "$lib/types/editor";
|
import type { FileEntry } from "$lib/types/editor";
|
||||||
import { editorStore } from "$lib/stores/editor";
|
import { editorStore } from "$lib/stores/editor";
|
||||||
|
import Self from "./FileTreeItem.svelte";
|
||||||
|
|
||||||
export let entry: FileEntry;
|
interface Props {
|
||||||
export let depth: number = 0;
|
entry: FileEntry;
|
||||||
|
depth?: number;
|
||||||
|
onContextMenu?: (event: MouseEvent, entry: FileEntry) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { entry, depth = 0, onContextMenu }: Props = $props();
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
if (entry.isDirectory) {
|
if (entry.isDirectory) {
|
||||||
@@ -20,9 +26,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: isExpanded = entry.isExpanded ?? false;
|
function handleContextMenu(event: MouseEvent) {
|
||||||
$: isLoading = entry.isLoading ?? false;
|
event.preventDefault();
|
||||||
$: children = entry.children ?? [];
|
event.stopPropagation();
|
||||||
|
onContextMenu?.(event, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpanded = $derived(entry.isExpanded ?? false);
|
||||||
|
const isLoading = $derived(entry.isLoading ?? false);
|
||||||
|
const children = $derived(entry.children ?? []);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="file-tree-item">
|
<div class="file-tree-item">
|
||||||
@@ -31,8 +43,9 @@
|
|||||||
class:directory={entry.isDirectory}
|
class:directory={entry.isDirectory}
|
||||||
class:file={!entry.isDirectory}
|
class:file={!entry.isDirectory}
|
||||||
style="padding-left: {depth * 16 + 8}px"
|
style="padding-left: {depth * 16 + 8}px"
|
||||||
on:click={handleClick}
|
onclick={handleClick}
|
||||||
on:keydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
|
oncontextmenu={handleContextMenu}
|
||||||
title={entry.path}
|
title={entry.path}
|
||||||
>
|
>
|
||||||
{#if entry.isDirectory}
|
{#if entry.isDirectory}
|
||||||
@@ -109,7 +122,7 @@
|
|||||||
{#if entry.isDirectory && isExpanded && children.length > 0}
|
{#if entry.isDirectory && isExpanded && children.length > 0}
|
||||||
<div class="children">
|
<div class="children">
|
||||||
{#each children as child (child.path)}
|
{#each children as child (child.path)}
|
||||||
<svelte:self entry={child} depth={depth + 1} />
|
<Self entry={child} depth={depth + 1} {onContextMenu} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
||||||
@@ -246,6 +246,100 @@ function createEditorStore() {
|
|||||||
isEditorVisible.update((v) => !v);
|
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() {
|
function showEditor() {
|
||||||
isEditorVisible.set(true);
|
isEditorVisible.set(true);
|
||||||
}
|
}
|
||||||
@@ -270,6 +364,11 @@ function createEditorStore() {
|
|||||||
toggleEditor,
|
toggleEditor,
|
||||||
showEditor,
|
showEditor,
|
||||||
hideEditor,
|
hideEditor,
|
||||||
|
createFile,
|
||||||
|
createDirectory,
|
||||||
|
deleteFile,
|
||||||
|
deleteDirectory,
|
||||||
|
refreshDirectory,
|
||||||
|
|
||||||
tabs: derived(state, ($state) => $state.tabs),
|
tabs: derived(state, ($state) => $state.tabs),
|
||||||
activeTab: derived(state, ($state) => $state.tabs.find((t) => t.id === $state.activeTabId)),
|
activeTab: derived(state, ($state) => $state.tabs.find((t) => t.id === $state.activeTabId)),
|
||||||
|
|||||||
@@ -238,6 +238,15 @@
|
|||||||
}
|
}
|
||||||
return;
|
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() {
|
async function handleInterrupt() {
|
||||||
|
|||||||
Reference in New Issue
Block a user