Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/opensidian/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"@sveltejs/vite-plugin-svelte": "catalog:",
"@tailwindcss/vite": "catalog:",
"bun-types": "catalog:",
"lucide-svelte": "^0.577.0",
"@lucide/svelte": "catalog:",
"svelte": "catalog:",
"svelte-check": "catalog:",
"svelte-sonner": "catalog:",
Expand Down
67 changes: 28 additions & 39 deletions apps/opensidian/src/lib/components/CommandPalette.svelte
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
<script lang="ts">
import type { FileId } from '@epicenter/filesystem';
import * as Command from '@epicenter/ui/command';
import {
CommandPalette,
type CommandPaletteItem,
} from '@epicenter/ui/command-palette';
import { getFileIcon } from '$lib/fs/file-icons';
import { fsState } from '$lib/fs/fs-state.svelte';

let open = $state(false);
let searchQuery = $state('');
let debouncedQuery = $state('');


// ── Collect all files recursively (only when palette is open) ────
type FileEntry = { id: FileId; name: string; parentDir: string };

Expand All @@ -25,11 +27,7 @@
const fullPath = fsState.getPathForId(childId) ?? '';
const lastSlash = fullPath.lastIndexOf('/');
const parentDir = lastSlash > 0 ? fullPath.slice(1, lastSlash) : '';
files.push({
id: childId,
name: row.name,
parentDir,
});
files.push({ id: childId, name: row.name, parentDir });
} else if (row.type === 'folder') {
collect(childId);
}
Expand Down Expand Up @@ -77,44 +75,35 @@
return [...startsWith, ...includes].slice(0, 50);
});

// ── Handlers ─────────────────────────────────────────────────────
function handleSelect(id: FileId) {
fsState.actions.selectFile(id);
open = false;
}
// ── Convert filtered files to palette items ─────────────────────
const fileItems = $derived<CommandPaletteItem[]>(
filteredFiles.map((file) => ({
id: file.id,
label: file.name,
description: file.parentDir || undefined,
icon: getFileIcon(file.name),
group: 'Files',
onSelect: () => fsState.actions.selectFile(file.id),
})),
);
</script>

function handleKeydown(e: KeyboardEvent) {
<svelte:window
onkeydown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
open = !open;
}
}
</script>
}}
/>

<svelte:window onkeydown={handleKeydown} />

<Command.Dialog
<CommandPalette
items={fileItems}
bind:open
bind:value={searchQuery}
shouldFilter={false}
placeholder="Search files..."
emptyMessage="No files found."
title="Search Files"
description="Search for a file by name"
shouldFilter={false}
>
<Command.Input placeholder="Search files..." bind:value={searchQuery} />
<Command.List>
<Command.Empty>No files found.</Command.Empty>
<Command.Group heading="Files">
{#each filteredFiles as file (file.id)}
<Command.Item value={file.id} onSelect={() => handleSelect(file.id)}>
{@const Icon = getFileIcon(file.name)}
<Icon class="h-4 w-4 shrink-0 text-muted-foreground" />
<span>{file.name}</span>
{#if file.parentDir}
<span class="ml-auto text-xs truncate text-muted-foreground">
{file.parentDir}
</span>
{/if}
</Command.Item>
{/each}
</Command.Group>
</Command.List>
</Command.Dialog>
/>
67 changes: 0 additions & 67 deletions apps/opensidian/src/lib/components/CreateDialog.svelte

This file was deleted.

44 changes: 43 additions & 1 deletion apps/opensidian/src/lib/components/FileTree.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
import * as Empty from '@epicenter/ui/empty';
import * as TreeView from '@epicenter/ui/tree-view';
import { fsState } from '$lib/fs/fs-state.svelte';
import DeleteConfirmation from './DeleteConfirmation.svelte';
import FileTreeItem from './FileTreeItem.svelte';
import InlineNameInput from './InlineNameInput.svelte';

let deleteDialogOpen = $state(false);

/**
* Flat list of visible item IDs in visual order.
Expand All @@ -26,7 +30,15 @@
return ids;
});

/** Whether an inline create/rename is active (suppresses tree keyboard shortcuts). */
const isEditing = $derived(
fsState.inlineCreate !== null || fsState.renamingId !== null,
);

function handleKeydown(e: KeyboardEvent) {
// Don't intercept keys while inline editing is active
if (isEditing) return;

const current = fsState.focusedId;
const currentIndex = current ? visibleIds.indexOf(current) : -1;

Expand Down Expand Up @@ -98,13 +110,34 @@
fsState.actions.focus(visibleIds.at(-1) ?? null);
break;
}
// ── Inline editing shortcuts ──────────────────────────────
case 'n':
case 'N': {
e.preventDefault();
fsState.actions.startCreate(e.shiftKey ? 'folder' : 'file');
break;
}
case 'F2': {
e.preventDefault();
if (current) fsState.actions.startRename(current);
break;
}
case 'Delete':
case 'Backspace': {
e.preventDefault();
if (!current) break;
// Select the focused item so DeleteConfirmation reads the right target
fsState.actions.selectFile(current);
deleteDialogOpen = true;
break;
}
default:
return; // don't prevent default for unhandled keys
}
}
</script>

{#if fsState.rootChildIds.length === 0}
{#if fsState.rootChildIds.length === 0 && !fsState.inlineCreate}
<Empty.Root class="border-0">
<Empty.Header>
<Empty.Title>No files yet</Empty.Title>
Expand All @@ -119,5 +152,14 @@
{#each fsState.rootChildIds as childId (childId)}
<FileTreeItem id={childId} />
{/each}
{#if fsState.inlineCreate?.parentId === null}
<InlineNameInput
icon={fsState.inlineCreate.type}
onConfirm={fsState.actions.confirmCreate}
onCancel={fsState.actions.cancelCreate}
/>
{/if}
</TreeView.Root>
{/if}

<DeleteConfirmation bind:open={deleteDialogOpen} />
81 changes: 53 additions & 28 deletions apps/opensidian/src/lib/components/FileTreeItem.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,9 @@
import * as TreeView from '@epicenter/ui/tree-view';
import { getFileIcon } from '$lib/fs/file-icons';
import { fsState } from '$lib/fs/fs-state.svelte';
import CreateDialog from './CreateDialog.svelte';
import DeleteConfirmation from './DeleteConfirmation.svelte';
import FileTreeItem from './FileTreeItem.svelte';
import RenameDialog from './RenameDialog.svelte';
import InlineNameInput from './InlineNameInput.svelte';

let { id }: { id: FileId } = $props();

Expand All @@ -17,34 +16,26 @@
const isSelected = $derived(fsState.activeFileId === id);
const children = $derived(isFolder ? fsState.getChildIds(id) : []);
const isFocused = $derived(fsState.focusedId === id);
const isRenaming = $derived(fsState.renamingId === id);
const showInlineCreate = $derived(fsState.inlineCreate?.parentId === id);

let createDialogOpen = $state(false);
let createDialogMode = $state<'file' | 'folder'>('file');
let renameDialogOpen = $state(false);
let deleteDialogOpen = $state(false);

function selectAndOpenCreate(mode: 'file' | 'folder') {
fsState.actions.selectFile(id);
createDialogMode = mode;
createDialogOpen = true;
}

function selectAndOpenRename() {
fsState.actions.selectFile(id);
renameDialogOpen = true;
}

function selectAndOpenDelete() {
fsState.actions.selectFile(id);
deleteDialogOpen = true;
}
</script>

{#if row}
<ContextMenu.Root>
<ContextMenu.Trigger>
{#snippet child({ props })}
{#if isFolder}
{#if isFolder && isRenaming}
<div {...props} role="treeitem" aria-expanded={isExpanded} class="w-full">
<InlineNameInput
defaultValue={row.name}
icon="folder"
onConfirm={fsState.actions.confirmRename}
onCancel={fsState.actions.cancelRename}
/>
</div>
{:else if isFolder}
<div {...props} role="treeitem" aria-expanded={isExpanded}>
<TreeView.Folder
name={row.name}
Expand All @@ -57,8 +48,24 @@
{#each children as childId (childId)}
<FileTreeItem id={childId} />
{/each}
{#if showInlineCreate}
<InlineNameInput
icon={fsState.inlineCreate?.type ?? 'file'}
onConfirm={fsState.actions.confirmCreate}
onCancel={fsState.actions.cancelCreate}
/>
{/if}
</TreeView.Folder>
</div>
{:else if isRenaming}
<div {...props} role="treeitem" class="w-full">
<InlineNameInput
defaultValue={row.name}
icon="file"
onConfirm={fsState.actions.confirmRename}
onCancel={fsState.actions.cancelRename}
/>
</div>
{:else}
<TreeView.File
{...props}
Expand All @@ -80,22 +87,40 @@
</ContextMenu.Trigger>
<ContextMenu.Content>
{#if isFolder}
<ContextMenu.Item onclick={() => selectAndOpenCreate('file')}>
<ContextMenu.Item onclick={() => {
fsState.actions.focus(id);
fsState.expandedIds.add(id);
fsState.actions.startCreate('file');
}}>
New File
<ContextMenu.Shortcut>N</ContextMenu.Shortcut>
</ContextMenu.Item>
<ContextMenu.Item onclick={() => selectAndOpenCreate('folder')}>
<ContextMenu.Item onclick={() => {
fsState.actions.focus(id);
fsState.expandedIds.add(id);
fsState.actions.startCreate('folder');
}}>
New Folder
<ContextMenu.Shortcut>⇧N</ContextMenu.Shortcut>
</ContextMenu.Item>
<ContextMenu.Separator />
{/if}
<ContextMenu.Item onclick={selectAndOpenRename}>Rename</ContextMenu.Item>
<ContextMenu.Item class="text-destructive" onclick={selectAndOpenDelete}>
<ContextMenu.Item onclick={() => fsState.actions.startRename(id)}>
Rename
<ContextMenu.Shortcut>F2</ContextMenu.Shortcut>
</ContextMenu.Item>
<ContextMenu.Item
class="text-destructive"
onclick={() => {
fsState.actions.selectFile(id);
deleteDialogOpen = true;
}}
>
Delete
<ContextMenu.Shortcut>⌫</ContextMenu.Shortcut>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>

<CreateDialog bind:open={createDialogOpen} mode={createDialogMode} />
<RenameDialog bind:open={renameDialogOpen} />
<DeleteConfirmation bind:open={deleteDialogOpen} />
{/if}
Loading
Loading