From 6aea5b5ec5ef928250d227e1a5cbd3a733ee2f58 Mon Sep 17 00:00:00 2001 From: Ethan Graf Date: Fri, 12 Jun 2026 22:21:22 -0400 Subject: [PATCH] Flesh out sidebar tree UI --- .eslintrc.json | 8 + src/components/AppSidebar.tsx | 389 ++++++++++-------- src/components/VaultTree.tsx | 286 ++++++++++++- .../providers/automergeFilesystemProvider.ts | 20 +- .../providers/habitatFilesystemProvider.ts | 20 +- .../providers/localFilesystemProvider.ts | 72 +++- .../providers/remoteSyncFilesystemProvider.ts | 20 +- .../providers/subtextFilesystemProvider.ts | 20 +- src/filesystem/types.ts | 25 +- src/filesystem/vaultFs.ts | 25 ++ src/main.ts | 41 ++ src/preload.ts | 7 + src/vite-env.d.ts | 6 + 13 files changed, 738 insertions(+), 201 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 11ec22b..fe24df1 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -21,6 +21,14 @@ } }, "rules": { + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_" + } + ], "no-restricted-syntax": [ "error", { diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index b726b88..f003ca1 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -1,15 +1,15 @@ import { useCallback, useEffect, - useMemo, useRef, useState, } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import type { FileSystemEntry, FileSystemProvider, FileSystemTreeNode } from '../filesystem/types'; -import { DocFileIcon, NewDocumentIcon, NewDirectoryIcon, StackedChevronsIcon, PlusIcon } from '../icons'; +import type { FileSystemProvider } from '../filesystem/types'; +import { NewDocumentIcon, NewDirectoryIcon, StackedChevronsIcon, PlusIcon } from '../icons'; import type { OpenDocRequest } from '../workspaces/workspace'; import { FileSystemTree } from './VaultTree'; +import type { ContextMenuItem, InlineCreateState } from './VaultTree'; type AppSidebarProps = { open: boolean; @@ -24,10 +24,6 @@ type AppSidebarProps = { type Selection = { providerId: string; entryId: string } | null; -function filesQueryKey(providerId: string) { - return ['files', providerId] as const; -} - function treeQueryKey(providerId: string) { return ['tree', providerId] as const; } @@ -37,13 +33,19 @@ type ProviderSectionProps = { selection: Selection; onSelect: (sel: Selection) => void; onOpenEntry?: (request: OpenDocRequest) => void; - creatingDir: boolean; - newDirName: string; - newDirError: string | null; - onNewDirNameChange: (name: string) => void; - onNewDirErrorChange: (error: string | null) => void; - onNewDirSubmit: (name: string) => void; - onNewDirCancel: () => void; + renameNodeId: string | null; + renameValue: string; + renameExt: string; + renameError: string | null; + onRenameValueChange: (value: string) => void; + onRenameSubmit: (value: string) => void; + onRenameCancel: () => void; + inlineCreate: InlineCreateState | null; + onInlineCreateChange: (name: string) => void; + onInlineCreateSubmit: (name: string) => void; + onInlineCreateCancel: () => void; + onContextMenuAction: (action: string, nodeId: string, nodeName: string, nodeType: 'file' | 'folder') => void; + onTreeRootRightClick: (action: string) => void; }; function ProviderSection({ @@ -51,16 +53,21 @@ function ProviderSection({ selection, onSelect, onOpenEntry, - creatingDir, - newDirName, - newDirError, - onNewDirNameChange, - onNewDirErrorChange, - onNewDirSubmit, - onNewDirCancel, + renameNodeId, + renameValue, + renameExt, + renameError, + onRenameValueChange, + onRenameSubmit, + onRenameCancel, + inlineCreate, + onInlineCreateChange, + onInlineCreateSubmit, + onInlineCreateCancel, + onContextMenuAction, + onTreeRootRightClick, }: ProviderSectionProps) { const queryClient = useQueryClient(); - const inputRef = useRef(null); // Tree query for all providers — flat sources return single-level trees. const treeQuery = useQuery({ @@ -78,16 +85,6 @@ function ProviderSection({ }); }, [provider, queryClient]); - // Focus inline input when entering directory creation mode. - useEffect(() => { - if (creatingDir) { - inputRef.current?.focus(); - } - }, [creatingDir]); - - const isSelected = (nodeId: string) => - selection?.providerId === provider.id && selection.entryId === nodeId; - const handleSelect = useCallback( (node: { id: string; name: string; type: 'file' | 'folder' }) => { if (node.type === 'file') { @@ -102,31 +99,40 @@ function ProviderSection({ [provider.id, onSelect, onOpenEntry], ); - const handleDirInputChange = (value: string) => { - onNewDirNameChange(value); - const trimmed = value.trim(); - const exists = treeQuery.data?.some((node) => node.name === trimmed); - onNewDirErrorChange(exists && trimmed.length > 0 ? `A directory named "${trimmed}" already exists` : null); - }; + const handleContextMenu = useCallback( + async (node: { id: string; name: string; type: 'file' | 'folder' }, items: ContextMenuItem[]) => { + const api = window.textile?.contextMenu; + if (!api) return; + const action = await api.show(items.map((item) => ({ id: item.id, label: item.label }))); + if (action) { + onContextMenuAction(action, node.id, node.name, node.type); + } + }, + [onContextMenuAction], + ); - const handleDirKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { + const handleTreeRootRightClick = useCallback( + async (e: React.MouseEvent) => { e.preventDefault(); - const trimmed = newDirName.trim(); - if (trimmed.length > 0 && !newDirError) { - onNewDirSubmit(trimmed); + const api = window.textile?.contextMenu; + if (!api) return; + const action = await api.show([ + { id: 'new-note', label: 'New Note' }, + { id: 'new-folder', label: 'New Folder' }, + ]); + if (action === 'new-note' || action === 'new-folder') { + onTreeRootRightClick(action); } - } else if (e.key === 'Escape') { - onNewDirCancel(); - } - }; + }, + [onTreeRootRightClick], + ); return ( -
+

{provider.displayName}

-
+
{!provider.isReady() ? (

{provider.idleHint} @@ -141,35 +147,28 @@ function ProviderSection({

) : ( <> - {creatingDir && ( -
-
- - handleDirInputChange(e.target.value)} - onKeyDown={handleDirKeyDown} - onBlur={onNewDirCancel} - className="flex-1 min-w-0 bg-transparent text-foreground text-[11px] outline-none border-0 p-0" - /> -
- {newDirError && ( -

- {newDirError} -

- )} +
+
- )} - - {treeQuery.data?.length === 0 && !creatingDir && ( + {treeQuery.data?.length === 0 && !inlineCreate && (

No files.

@@ -253,99 +252,151 @@ export function AppSidebar({ }: AppSidebarProps) { const queryClient = useQueryClient(); const [selection, setSelection] = useState(null); - const [creatingFor, setCreatingFor] = useState(null); const [createError, setCreateError] = useState(null); - const [creatingDir, setCreatingDir] = useState(false); - const [newDirName, setNewDirName] = useState(''); - const [newDirError, setNewDirError] = useState(null); + const [inlineCreate, setInlineCreate] = useState(null); + const [renameNodeId, setRenameNodeId] = useState(null); + const [renameValue, setRenameValue] = useState(''); + const [renameExt, setRenameExt] = useState(''); + const [renameError, setRenameError] = useState(null); const selectedProvider = providers.find((p) => p.id === selectedProviderId); // Clear stale errors and creation state when switching providers. useEffect(() => { setCreateError(null); - setCreatingDir(false); - setNewDirName(''); - setNewDirError(null); + setInlineCreate(null); + setRenameNodeId(null); + setRenameValue(''); + setRenameExt(''); + setRenameError(null); }, [selectedProviderId]); - const sortTreeNodes = (a: FileSystemTreeNode, b: FileSystemTreeNode): number => { - if (a.type === b.type) return a.name.localeCompare(b.name); - return a.type === 'folder' ? -1 : 1; - }; + const handleContextMenuAction = useCallback( + async (action: string, nodeId: string, nodeName: string, _nodeType: 'file' | 'folder') => { + if (!selectedProvider) return; + switch (action) { + case 'new-note': { + setInlineCreate({ parentId: nodeId, type: 'file', name: '', error: null }); + break; + } + case 'new-folder': { + setInlineCreate({ parentId: nodeId, type: 'folder', name: '', error: null }); + break; + } + case 'copy': { + try { + await selectedProvider.copyFile(nodeId); + void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); + } catch (e: unknown) { + setCreateError(e instanceof Error ? e.message : 'Could not copy file'); + } + break; + } + case 'copy-path': { + const path = selectedProvider.getPath(nodeId); + if (path) { + void navigator.clipboard.writeText(path); + } + break; + } + case 'rename': { + setRenameNodeId(nodeId); + const ext = nodeName.includes('.') ? nodeName.slice(nodeName.lastIndexOf('.')) : ''; + const base = ext ? nodeName.slice(0, -ext.length) : nodeName; + setRenameValue(base); + setRenameExt(ext); + setRenameError(null); + break; + } + case 'delete': { + if (window.confirm(`Delete "${nodeName}"? This cannot be undone.`)) { + try { + await selectedProvider.deleteFile(nodeId); + void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); + } catch (e: unknown) { + setCreateError(e instanceof Error ? e.message : 'Could not delete'); + } + } + break; + } + } + }, + [selectedProvider, queryClient], + ); - const handleCreate = useCallback( - async (provider: FileSystemProvider) => { + const handleInlineCreateSubmit = useCallback( + async (name: string) => { + if (!selectedProvider || !inlineCreate) return; setCreateError(null); - setCreatingFor(provider.id); - try { - const entry = await provider.createFile(); - queryClient.setQueryData( - filesQueryKey(provider.id), - (entries) => - entries && !entries.some((e) => e.id === entry.id) - ? [...entries, entry] - : (entries ?? [entry]), - ); - // Optimistically insert into tree cache. - queryClient.setQueryData( - treeQueryKey(provider.id), - (nodes) => { - const newNode: FileSystemTreeNode = { id: entry.id, name: entry.name, type: 'file' }; - if (!nodes) return [newNode]; - const next = [...nodes, newNode]; - next.sort(sortTreeNodes); - return next; - }, - ); - setSelection({ providerId: provider.id, entryId: entry.id }); - onOpenEntry?.({ - providerId: provider.id, - entryId: entry.id, - title: entry.name, - }); - void queryClient.invalidateQueries({ queryKey: treeQueryKey(provider.id) }); - } catch (e: unknown) { - setCreateError( - e instanceof Error ? e.message : 'Could not create document', - ); - } finally { - setCreatingFor(null); + const trimmed = name.trim(); + + if (inlineCreate.type === 'file') { + try { + const entry = await selectedProvider.createFile(inlineCreate.parentId, trimmed || undefined); + setInlineCreate(null); + setSelection({ providerId: selectedProvider.id, entryId: entry.id }); + onOpenEntry?.({ + providerId: selectedProvider.id, + entryId: entry.id, + title: entry.name, + }); + void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); + } catch (e: unknown) { + setInlineCreate({ ...inlineCreate, error: e instanceof Error ? e.message : 'Could not create file' }); + } + } else { + try { + await selectedProvider.createDirectory(trimmed || 'Untitled', inlineCreate.parentId); + setInlineCreate(null); + void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); + } catch (e: unknown) { + setInlineCreate({ ...inlineCreate, error: e instanceof Error ? e.message : 'Could not create directory' }); + } } }, - [queryClient, setSelection, onOpenEntry], + [selectedProvider, inlineCreate, queryClient, onOpenEntry, setSelection], ); - const handleCreateDirectory = useCallback( - async (name: string) => { - if (!selectedProvider) return; + const handleInlineCreateCancel = useCallback(() => { + setInlineCreate(null); + }, []); + + const handleInlineCreateChange = useCallback((name: string) => { + setInlineCreate((prev) => prev ? { ...prev, name, error: null } : null); + }, []); + + const handleRenameSubmit = useCallback( + async (newName: string) => { + if (!selectedProvider || !renameNodeId) return; + const trimmed = newName.trim(); + if (!trimmed) { + setRenameNodeId(null); + setRenameValue(''); + setRenameExt(''); + setRenameError(null); + return; + } + const finalName = trimmed + renameExt; try { - const entry = await selectedProvider.createDirectory(name); - // Optimistically insert into tree cache. - queryClient.setQueryData( - treeQueryKey(selectedProvider.id), - (nodes) => { - const newNode: FileSystemTreeNode = { id: entry.id, name: entry.name, type: 'folder', children: [] }; - if (!nodes) return [newNode]; - const next = [...nodes, newNode]; - next.sort(sortTreeNodes); - return next; - }, - ); - setCreatingDir(false); - setNewDirName(''); - setNewDirError(null); + await selectedProvider.renameFile(renameNodeId, finalName); + setRenameNodeId(null); + setRenameValue(''); + setRenameExt(''); + setRenameError(null); void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); } catch (e: unknown) { - setNewDirError( - e instanceof Error ? e.message : 'Could not create directory', - ); + setRenameError(e instanceof Error ? e.message : 'Could not rename'); } }, - [selectedProvider, queryClient], + [selectedProvider, renameNodeId, renameExt, queryClient], ); - const supportsDirectory = !!selectedProvider; + const handleRenameCancel = useCallback(() => { + setRenameNodeId(null); + setRenameValue(''); + setRenameExt(''); + setRenameError(null); + }, []); return (
{hasChildren && expanded && (
+ {isCreatingHere && ( + + )} {node.children!.map((child) => ( ))}
)} + {node.type === 'folder' && !hasChildren && isCreatingHere && ( +
+ +
+ )}
); } -export function FileSystemTree({ nodes, onSelect, selectedId }: FileSystemTreeProps) { +export function FileSystemTree({ + nodes, + onSelect, + onContextMenu, + selectedId, + renameNodeId, + renameValue, + renameExt, + renameError, + onRenameValueChange, + onRenameSubmit, + onRenameCancel, + inlineCreate, + onInlineCreateChange, + onInlineCreateSubmit, + onInlineCreateCancel, +}: FileSystemTreeProps) { + const isCreatingAtRoot = inlineCreate && inlineCreate.parentId === undefined; + return ( -
+
+ {isCreatingAtRoot && ( + + )} {nodes.map((node) => ( ))}
diff --git a/src/filesystem/providers/automergeFilesystemProvider.ts b/src/filesystem/providers/automergeFilesystemProvider.ts index fcdf5a7..eef950e 100644 --- a/src/filesystem/providers/automergeFilesystemProvider.ts +++ b/src/filesystem/providers/automergeFilesystemProvider.ts @@ -48,7 +48,7 @@ export class AutomergeFilesystemProvider implements FileSystemProvider { return subscribeEntryTitleChanges(listener); } - async createFile(): Promise { + async createFile(_parentId?: string, _name?: string): Promise { const did = habitatAuth.getAuthInfo()?.did; if (!did) { throw new Error( @@ -59,10 +59,26 @@ export class AutomergeFilesystemProvider implements FileSystemProvider { return { id: uri, name: 'Untitled' }; } - async createDirectory(_name: string): Promise { + async createDirectory(_name: string, _parentId?: string): Promise { throw new Error('Automerge Docs does not support directories'); } + async renameFile(_entryId: string, _newName: string): Promise { + throw new Error('Automerge Docs does not support renaming'); + } + + async deleteFile(_entryId: string): Promise { + throw new Error('Automerge Docs does not support deleting'); + } + + async copyFile(_entryId: string): Promise { + throw new Error('Automerge Docs does not support copying'); + } + + getPath(_entryId: string): string { + return ''; + } + private async listAutomergeDocsRecords( options: { limit?: number; cursor?: string } = {}, ): Promise { diff --git a/src/filesystem/providers/habitatFilesystemProvider.ts b/src/filesystem/providers/habitatFilesystemProvider.ts index b085adb..ced9903 100644 --- a/src/filesystem/providers/habitatFilesystemProvider.ts +++ b/src/filesystem/providers/habitatFilesystemProvider.ts @@ -50,7 +50,7 @@ export class HabitatFilesystemProvider implements FileSystemProvider { return subscribeEntryTitleChanges(listener); } - async createFile(): Promise { + async createFile(_parentId?: string, _name?: string): Promise { const did = habitatAuth.getAuthInfo()?.did; if (!did) { throw new Error( @@ -61,10 +61,26 @@ export class HabitatFilesystemProvider implements FileSystemProvider { return { id: uri, name: 'Untitled' }; } - async createDirectory(_name: string): Promise { + async createDirectory(_name: string, _parentId?: string): Promise { throw new Error('Habitat Docs does not support directories'); } + async renameFile(_entryId: string, _newName: string): Promise { + throw new Error('Habitat Docs does not support renaming'); + } + + async deleteFile(_entryId: string): Promise { + throw new Error('Habitat Docs does not support deleting'); + } + + async copyFile(_entryId: string): Promise { + throw new Error('Habitat Docs does not support copying'); + } + + getPath(_entryId: string): string { + return ''; + } + private async listHabitatDocsRecords( options: { limit?: number; cursor?: string } = {}, ): Promise { diff --git a/src/filesystem/providers/localFilesystemProvider.ts b/src/filesystem/providers/localFilesystemProvider.ts index 0f334b9..9dd9102 100644 --- a/src/filesystem/providers/localFilesystemProvider.ts +++ b/src/filesystem/providers/localFilesystemProvider.ts @@ -1,7 +1,7 @@ import type { FileSystemEntry, FileSystemProvider, FileSystemTreeNode, FilesystemApi } from '../types'; import type { DocumentProvider } from '../../documents/types'; import type { EditorKind } from '../../editors/types'; -import { join } from '../vaultFs'; +import { basename, dirname, join } from '../vaultFs'; export type LocalFilesystemProviderOptions = { /** Absolute filesystem path to the root directory. */ @@ -72,17 +72,30 @@ export class LocalFilesystemProvider implements FileSystemProvider { return tree.map((node) => ({ id: node.id, name: node.name })); } - async createFile(): Promise { - let fileName = 'Untitled.md'; - let fullPath = join(this.rootPath, fileName); - let counter = 1; - try { + async createFile(parentId?: string, name?: string): Promise { + const parent = parentId ?? this.rootPath; + let fileName: string; + if (!name || !name.trim()) { + fileName = 'Untitled.md'; + let fullPath = join(parent, fileName); + let counter = 1; while (await this.fs.fileExists(fullPath)) { const base = fileName.replace(/\.md$/, ''); fileName = `${base}-${counter}.md`; - fullPath = join(this.rootPath, fileName); + fullPath = join(parent, fileName); counter++; } + } else { + fileName = name.trim(); + if (!fileName.includes('.')) { + fileName += '.md'; + } + } + const fullPath = join(parent, fileName); + try { + if (await this.fs.fileExists(fullPath)) { + throw new Error(`A file named "${fileName}" already exists`); + } await this.fs.writeFile(fullPath, ''); } catch (error) { throw new Error( @@ -92,8 +105,9 @@ export class LocalFilesystemProvider implements FileSystemProvider { return { id: fullPath, name: fileName }; } - async createDirectory(name: string): Promise { - const dirPath = join(this.rootPath, name); + async createDirectory(name: string, parentId?: string): Promise { + const parent = parentId ?? this.rootPath; + const dirPath = join(parent, name); try { if (await this.fs.fileExists(dirPath)) { throw new Error(`A directory named "${name}" already exists`); @@ -107,6 +121,46 @@ export class LocalFilesystemProvider implements FileSystemProvider { return { id: dirPath, name }; } + async renameFile(entryId: string, newName: string): Promise { + const parent = dirname(entryId); + const newPath = join(parent, newName); + try { + await this.fs.rename(entryId, newPath); + } catch (error) { + throw new Error( + `Could not rename: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return { id: newPath, name: newName }; + } + + async deleteFile(entryId: string): Promise { + await this.fs.deletePath(entryId); + } + + async copyFile(entryId: string): Promise { + const name = basename(entryId); + const parent = dirname(entryId); + const extMatch = name.match(/(\.[a-zA-Z0-9]+)$/); + const ext = extMatch ? extMatch[1] : ''; + const base = ext ? name.slice(0, -ext.length) : name; + let newName = `${base} copy${ext}`; + let newPath = join(parent, newName); + let counter = 2; + while (await this.fs.fileExists(newPath)) { + newName = `${base} copy ${counter}${ext}`; + newPath = join(parent, newName); + counter++; + } + const content = await this.fs.readFile(entryId); + await this.fs.writeFile(newPath, content); + return { id: newPath, name: newName }; + } + + getPath(entryId: string): string { + return entryId; + } + // --- Local directory scanning (via IPC) --- private async scanTree(dirPath: string): Promise { diff --git a/src/filesystem/providers/remoteSyncFilesystemProvider.ts b/src/filesystem/providers/remoteSyncFilesystemProvider.ts index b0cd9b0..b7774da 100644 --- a/src/filesystem/providers/remoteSyncFilesystemProvider.ts +++ b/src/filesystem/providers/remoteSyncFilesystemProvider.ts @@ -58,11 +58,27 @@ export class RemoteSyncFilesystemProvider implements FileSystemProvider { return []; } - async createFile(): Promise { + async createFile(_parentId?: string, _name?: string): Promise { throw new Error('Remote sync is not yet implemented'); } - async createDirectory(_name: string): Promise { + async createDirectory(_name: string, _parentId?: string): Promise { throw new Error('Remote sync is not yet implemented'); } + + async renameFile(_entryId: string, _newName: string): Promise { + throw new Error('Remote sync is not yet implemented'); + } + + async deleteFile(_entryId: string): Promise { + throw new Error('Remote sync is not yet implemented'); + } + + async copyFile(_entryId: string): Promise { + throw new Error('Remote sync is not yet implemented'); + } + + getPath(_entryId: string): string { + return ''; + } } diff --git a/src/filesystem/providers/subtextFilesystemProvider.ts b/src/filesystem/providers/subtextFilesystemProvider.ts index efc2e48..54baf24 100644 --- a/src/filesystem/providers/subtextFilesystemProvider.ts +++ b/src/filesystem/providers/subtextFilesystemProvider.ts @@ -57,12 +57,28 @@ export class SubtextFilesystemProvider implements FileSystemProvider { return subscribeSubtextTitleChanges(listener); } - async createFile(): Promise { + async createFile(_parentId?: string, _name?: string): Promise { const handle = await this.documents.createDocument(); return { id: handle.id, name: handle.getTitle() }; } - async createDirectory(_name: string): Promise { + async createDirectory(_name: string, _parentId?: string): Promise { throw new Error('Subtext does not support directories'); } + + async renameFile(_entryId: string, _newName: string): Promise { + throw new Error('Subtext does not support renaming'); + } + + async deleteFile(_entryId: string): Promise { + throw new Error('Subtext does not support deleting'); + } + + async copyFile(_entryId: string): Promise { + throw new Error('Subtext does not support copying'); + } + + getPath(_entryId: string): string { + return ''; + } } diff --git a/src/filesystem/types.ts b/src/filesystem/types.ts index baa42c4..0117785 100644 --- a/src/filesystem/types.ts +++ b/src/filesystem/types.ts @@ -55,12 +55,30 @@ export type FileSystemProvider = { * Create a new persisted entry in this source (e.g. a new Habitat docs * record). The shell optimistically inserts it, then refreshes the listing. */ - createFile(): Promise; + createFile(parentId?: string, name?: string): Promise; /** * Create a new directory in this source. * Providers without directory support may throw or no-op. */ - createDirectory(name: string): Promise; + createDirectory(name: string, parentId?: string): Promise; + /** + * Rename an entry (file or directory). + * Returns the updated entry with the new id and name. + */ + renameFile(entryId: string, newName: string): Promise; + /** + * Delete an entry (file or directory) permanently. + */ + deleteFile(entryId: string): Promise; + /** + * Duplicate a file. Returns the new entry. + */ + copyFile(entryId: string): Promise; + /** + * Get the absolute filesystem path for an entry. + * Used for "Copy Path" to clipboard. + */ + getPath(entryId: string): string; /** * When implemented, called when a listed entry's display name changes * (e.g. habitat doc heading edits). Used to keep the sidebar in sync. @@ -85,4 +103,7 @@ export interface FilesystemApi { writeFile(filePath: string, content: string): Promise; fileExists(filePath: string): Promise; mkdir(dirPath: string): Promise; + rename(oldPath: string, newPath: string): Promise; + deletePath(filePath: string): Promise; + copyFile(srcPath: string, destPath: string): Promise; } diff --git a/src/filesystem/vaultFs.ts b/src/filesystem/vaultFs.ts index 5f61f0c..f7d68e9 100644 --- a/src/filesystem/vaultFs.ts +++ b/src/filesystem/vaultFs.ts @@ -7,6 +7,9 @@ export type VaultIpcApi = { writeFile: (filePath: string, content: string) => Promise; fileExists: (filePath: string) => Promise; mkdir: (dirPath: string) => Promise; + rename: (oldPath: string, newPath: string) => Promise; + delete: (filePath: string) => Promise; + copyFile: (srcPath: string, destPath: string) => Promise; }; export class VaultFilesystemApi implements FilesystemApi { @@ -31,6 +34,18 @@ export class VaultFilesystemApi implements FilesystemApi { async mkdir(dirPath: string): Promise { return this.ipc.mkdir(dirPath); } + + async rename(oldPath: string, newPath: string): Promise { + return this.ipc.rename(oldPath, newPath); + } + + async deletePath(filePath: string): Promise { + return this.ipc.delete(filePath); + } + + async copyFile(srcPath: string, destPath: string): Promise { + return this.ipc.copyFile(srcPath, destPath); + } } /** @@ -42,6 +57,16 @@ export function basename(filePath: string): string { return parts[parts.length - 1] || ''; } +/** + * Get the directory name of a path (everything except the last component). + * Works with both / and \ separators. + */ +export function dirname(filePath: string): string { + const parts = filePath.split(/[/\\]/); + parts.pop(); + return parts.join('/') || '/'; +} + /** * Join path segments. */ diff --git a/src/main.ts b/src/main.ts index d7bb3f8..b875d96 100644 --- a/src/main.ts +++ b/src/main.ts @@ -374,6 +374,47 @@ ipcMain.handle('vault:mkdir', async (_event, { dirPath }) => { await fs.mkdir(expandPath(dirPath), { recursive: true }); }); +ipcMain.handle('vault:rename', async (_event, { oldPath, newPath }) => { + await fs.rename(expandPath(oldPath), expandPath(newPath)); +}); + +ipcMain.handle('vault:delete', async (_event, { filePath }) => { + await fs.rm(expandPath(filePath), { recursive: true, force: true }); +}); + +ipcMain.handle('vault:copy-file', async (_event, { srcPath, destPath }) => { + await fs.copyFile(expandPath(srcPath), expandPath(destPath)); +}); + +ipcMain.handle('context-menu:show', async (event, { items }: { items: Array<{ id: string; label: string; type?: 'normal' | 'separator' }> }) => { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return null; + return new Promise((resolve) => { + let resolved = false; + const menu = Menu.buildFromTemplate( + items.map((item) => ({ + label: item.label, + type: item.type === 'separator' ? 'separator' as const : 'normal' as const, + click: () => { + if (!resolved) { + resolved = true; + resolve(item.id); + } + }, + })), + ); + menu.popup({ + window: win, + callback: () => { + if (!resolved) { + resolved = true; + resolve(null); + } + }, + }); + }); +}); + ipcMain.handle( 'auth:start-habitat-login', async (_event, payload: { redirectUri: string; url: string }) => { diff --git a/src/preload.ts b/src/preload.ts index 9aafbbb..74d704e 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -9,6 +9,10 @@ contextBridge.exposeInMainWorld('textile', { }, startHabitatLogin: (url: string, redirectUri: string) => ipcRenderer.invoke('auth:start-habitat-login', { redirectUri, url }), + contextMenu: { + show: (items: Array<{ id: string; label: string; type?: 'normal' | 'separator' }>) => + ipcRenderer.invoke('context-menu:show', { items }), + }, vault: { showOpenDialog: () => ipcRenderer.invoke('vault:show-open-dialog'), readdir: (dirPath: string) => ipcRenderer.invoke('vault:readdir', { dirPath }), @@ -16,6 +20,9 @@ contextBridge.exposeInMainWorld('textile', { writeFile: (filePath: string, content: string) => ipcRenderer.invoke('vault:write-file', { filePath, content }), fileExists: (filePath: string) => ipcRenderer.invoke('vault:file-exists', { filePath }), mkdir: (dirPath: string) => ipcRenderer.invoke('vault:mkdir', { dirPath }), + rename: (oldPath: string, newPath: string) => ipcRenderer.invoke('vault:rename', { oldPath, newPath }), + delete: (filePath: string) => ipcRenderer.invoke('vault:delete', { filePath }), + copyFile: (srcPath: string, destPath: string) => ipcRenderer.invoke('vault:copy-file', { srcPath, destPath }), }, subtext: { getFullState: (vaultPath: string) => diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 969dec0..e2dd9d2 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -6,6 +6,9 @@ declare global { platform: NodeJS.Platform; onOpenSettings: (callback: () => void) => () => void; startHabitatLogin: (url: string, redirectUri: string) => Promise; + contextMenu: { + show: (items: Array<{ id: string; label: string; type?: 'normal' | 'separator' }>) => Promise; + }; vault: { showOpenDialog: () => Promise<{ canceled: boolean; filePaths: string[] }>; readdir: (dirPath: string) => Promise<{ name: string; isDirectory: boolean; isFile: boolean }[]>; @@ -13,6 +16,9 @@ declare global { writeFile: (filePath: string, content: string) => Promise; fileExists: (filePath: string) => Promise; mkdir: (dirPath: string) => Promise; + rename: (oldPath: string, newPath: string) => Promise; + delete: (filePath: string) => Promise; + copyFile: (srcPath: string, destPath: string) => Promise; }; subtext: { getFullState: (vaultPath: string) => Promise; -- 2.51.2