mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-13 10:14:20 +00:00
* Cursor: Apply local changes for cloud agent * fix(terminal): stop first SSH fail under StrictMode remount Defer backend boot past the double-invoke, cancel superseded vault init, gate start on vault hydration, and harden hotkey/attach cleanup so the first connect no longer races a stale close or empty keychain. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * perf: finish StrictMode mega-subscriber plan (accent isolation + docs) Stabilize published terminal themes across custom-accent drag, apply accent at chrome/log leaves and CSS injection only, land Phase 0 design/plan docs, and lock Host-bag / Bugbot-clean contracts for the isolation checklist. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * docs: add StrictMode mega-subscriber design and plan artifacts Force-add Phase 0 spec/plan under docs/superpowers (gitignored locally) so the delivery branch carries the approved design and plan copy. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: clear remaining StrictMode doubles and notes editor jank Gate every AI send and reject concurrent runTurn, latch tray connect flush and settings persistMounted remounts, and stop notes content drafts from rebuilding MDXEditor/shell on each keystroke. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * perf: gate hidden notes panel subscribe and skip host-link annotate on keystrokes Hidden terminal notes mounts keep a live snapshot read but stop listening to publishes. Host-link DOM annotation follows hosts/mode (preview MutationObserver) instead of every markdown value change. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: latch SSH transport idle TTL notify under StrictMode Avoid duplicate main-process notifySettingsChanged on effect double-invoke when the idle TTL value has not changed. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * perf: freeze inactive vault notes against hosts churn and prefetch MDXEditor Gate the retained VaultNotesSection notesStore subscription while hidden, ignore hosts identity in its memo compare when inactive, and warm the lazy InlineMarkdownEditor chunk when notes become active. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: clear remaining AI StrictMode double-fire hazards Latch Ask-AI terminal selection on requestId before draft mutation, hoist Codex App Server IPC bridge to App (ref-counted), and make Catty requestApproval join an existing waiter for the same toolCallId. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: remount-safe AI send latch and skip stale MCP approval IPC Replace the component sendInFlightRef with a module key latch so StrictMode panel remount cannot reopen a mid-flight send, and no-op resolveApproval when the pending entry is already drained. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * test: allow gated useNotesStore in VaultNotesSection memo contract The retained notes section now passes enabled:isActive so hidden mounts do not subscribe; update the VaultView memo source assertion accordingly. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: keep note drafts across mode toggle and external note publishes Flush ref-only content before edit/preview remount, and skip prop→editor setMarkdown while a local draft diverges from the last synced value. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * chore: strip StrictMode sermon comments after Bugbot/Comment Sicko pass Keep effect-order and public-export contracts; drop remount/perf narration that the latches and tests already encode. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: remount-safe Ask-AI terminal selection latch Move consumed requestIds to a module Set so StrictMode remount cannot append the same selection chip twice before parent state clears. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * chore: drop remount latch comment Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
1844 lines
67 KiB
TypeScript
1844 lines
67 KiB
TypeScript
import {
|
|
ArrowLeft,
|
|
Download,
|
|
Edit2,
|
|
Expand,
|
|
FileText,
|
|
Folder,
|
|
FolderPlus,
|
|
Glasses,
|
|
MoreHorizontal,
|
|
Minimize2,
|
|
PencilLine,
|
|
Plus,
|
|
Search,
|
|
Upload,
|
|
X,
|
|
} from "lucide-react";
|
|
import React, { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import { useI18n } from "../../application/i18n/I18nProvider";
|
|
import { useApplicationBackend } from "../../application/state/useApplicationBackend";
|
|
import { useStoredNumber } from "../../application/state/useStoredNumber";
|
|
import { useStoredString } from "../../application/state/useStoredString";
|
|
import {
|
|
ancestorNoteGroupPaths,
|
|
buildVaultNoteMarkdownExportFiles,
|
|
cleanNoteGroupPath,
|
|
getNoteGroupParentPath,
|
|
isNoteGroupInside,
|
|
joinNoteGroupPath,
|
|
matchesVaultNoteSearch,
|
|
importMarkdownPayloadsToVaultNotes,
|
|
normalizeNoteGroups,
|
|
normalizeVaultNotes,
|
|
remapExpandedNoteGroupPaths,
|
|
replaceNoteGroupPrefix,
|
|
resolveMovedNoteGroupPath,
|
|
sanitizeNoteExportFileNamePart,
|
|
type VaultNotesExportScope,
|
|
} from "../../domain/notes";
|
|
import { getNextVaultOrder, reorderVaultItems, reorderVaultStrings, sortByVaultOrder } from "../../domain/vaultOrder";
|
|
import {
|
|
STORAGE_KEY_VAULT_NOTES_EDITOR_MODE,
|
|
STORAGE_KEY_VAULT_NOTES_TREE_WIDTH,
|
|
} from "../../infrastructure/config/storageKeys";
|
|
import { logger } from "../../lib/logger";
|
|
import { cn } from "../../lib/utils";
|
|
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from "../terminalLayer/terminalSidePanelChrome";
|
|
import { readTextFile } from "../../lib/readTextFile";
|
|
import { buildTextFilesZipBlob } from "../../lib/textZip";
|
|
import type { Host, VaultNote } from "../../types";
|
|
import { Button } from "../ui/button";
|
|
import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuTrigger,
|
|
} from "../ui/context-menu";
|
|
import { Dropdown, DropdownContent, DropdownTrigger } from "../ui/dropdown";
|
|
import { Input } from "../ui/input";
|
|
import { ScrollArea } from "../ui/scroll-area";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
|
import { toast } from "../ui/toast";
|
|
import {
|
|
VaultTreeGroupRow,
|
|
VaultTreeInlineRenameInput,
|
|
VaultTreeItemRow,
|
|
} from "../vault/VaultTreeRow";
|
|
import { VaultDeleteConfirmDialog } from "../vault/VaultDeleteConfirmDialog";
|
|
import {
|
|
clearVaultDropIndicator,
|
|
getVaultDropIntent,
|
|
getVaultDropPosition,
|
|
type VaultDropPosition,
|
|
hasVaultDragType,
|
|
markVaultDropIndicator,
|
|
markVaultInsideDropIndicator,
|
|
} from "../vault/vaultReorderDrag";
|
|
import type { NoteEditorMode } from "./InlineMarkdownEditor";
|
|
import { NoteTitleInput } from "./NoteTitleInput";
|
|
|
|
const InlineMarkdownEditor = lazy(() =>
|
|
import("./InlineMarkdownEditor").then((module) => ({ default: module.InlineMarkdownEditor })),
|
|
);
|
|
|
|
/** Warm the MDXEditor chunk before Suspense. */
|
|
export function prefetchInlineMarkdownEditor(): void {
|
|
void import("./InlineMarkdownEditor");
|
|
}
|
|
|
|
interface NoteFolderNode {
|
|
name: string;
|
|
path: string;
|
|
children: NoteFolderNode[];
|
|
notes: VaultNote[];
|
|
}
|
|
|
|
type NotesToolbarPanel = "search" | null;
|
|
|
|
const toolbarIconButtonClass = "netcatty-tab h-6 w-6 shrink-0 rounded-md p-0 hover:bg-transparent";
|
|
const menuItemClass = "flex h-8 w-full items-center rounded-md px-3 text-left text-sm hover:bg-secondary";
|
|
const NOTES_TREE_DEFAULT_WIDTH = 300;
|
|
const NOTES_TREE_MIN_WIDTH = 220;
|
|
const NOTES_TREE_MAX_WIDTH = 520;
|
|
const NOTE_DRAG_TYPE = "application/x-netcatty-note-id";
|
|
const NOTE_GROUP_DRAG_TYPE = "application/x-netcatty-note-group-path";
|
|
|
|
export const normalizeNoteEditorMode = (value: string | null): NoteEditorMode | null =>
|
|
value === "edit" || value === "preview" ? value : null;
|
|
|
|
const isNoteEditorMode = (value: string | null): value is NoteEditorMode =>
|
|
normalizeNoteEditorMode(value) !== null;
|
|
|
|
const InlineMarkdownEditorFallback = () => (
|
|
<div
|
|
className="netcatty-lazy-fade-in min-h-[420px]"
|
|
data-notes-editor-loading="true"
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
|
|
export interface NotesManagerProps {
|
|
notes: VaultNote[];
|
|
noteGroups: string[];
|
|
hosts: Host[];
|
|
onUpdateNotes: (notes: VaultNote[]) => void;
|
|
onUpdateNoteGroups: (groups: string[]) => void;
|
|
onOpenHost?: (host: Host, source?: { noteId: string }) => void;
|
|
displayMode?: "full" | "sidebar";
|
|
/** When false (hidden retained mount), flush any pending draft immediately. */
|
|
isActive?: boolean;
|
|
openNoteId?: string | null;
|
|
openNoteRequestId?: number | null;
|
|
/** Called after a one-shot openNoteId focus request has been applied. */
|
|
onOpenNoteIdHandled?: () => void;
|
|
}
|
|
|
|
type HoverActionMenuProps = {
|
|
children: React.ReactNode | ((closeMenu: () => void) => React.ReactNode);
|
|
className?: string;
|
|
};
|
|
|
|
const HoverActionMenu: React.FC<HoverActionMenuProps> = ({ children, className }) => {
|
|
const [open, setOpen] = useState(false);
|
|
const closeTimerRef = useRef<number | null>(null);
|
|
|
|
const cancelClose = () => {
|
|
if (closeTimerRef.current !== null) {
|
|
window.clearTimeout(closeTimerRef.current);
|
|
closeTimerRef.current = null;
|
|
}
|
|
};
|
|
|
|
const scheduleClose = () => {
|
|
cancelClose();
|
|
closeTimerRef.current = window.setTimeout(() => setOpen(false), 140);
|
|
};
|
|
|
|
useEffect(() => () => cancelClose(), []);
|
|
|
|
const closeMenu = () => {
|
|
cancelClose();
|
|
setOpen(false);
|
|
};
|
|
|
|
return (
|
|
<Dropdown open={open} onOpenChange={setOpen}>
|
|
<DropdownTrigger asChild toggleOnClick={false}>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"flex h-6 w-6 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:bg-secondary/80 hover:text-foreground group-hover:opacity-100 data-[open=true]:opacity-100",
|
|
className,
|
|
)}
|
|
data-open={open ? "true" : "false"}
|
|
onMouseEnter={() => {
|
|
cancelClose();
|
|
setOpen(true);
|
|
}}
|
|
onMouseLeave={scheduleClose}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
cancelClose();
|
|
setOpen(true);
|
|
}}
|
|
>
|
|
<MoreHorizontal size={14} />
|
|
</button>
|
|
</DropdownTrigger>
|
|
<DropdownContent
|
|
align="end"
|
|
className="min-w-[148px]"
|
|
onMouseEnter={cancelClose}
|
|
onMouseLeave={scheduleClose}
|
|
>
|
|
{typeof children === "function" ? children(closeMenu) : children}
|
|
</DropdownContent>
|
|
</Dropdown>
|
|
);
|
|
};
|
|
|
|
const createNote = (group: string | null, order: number): VaultNote => {
|
|
const now = Date.now();
|
|
return {
|
|
id: crypto.randomUUID(),
|
|
title: "",
|
|
content: "",
|
|
group: group || undefined,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
order,
|
|
};
|
|
};
|
|
|
|
const sortNoteItems = (items: VaultNote[]): VaultNote[] => sortByVaultOrder(items);
|
|
|
|
const sortFolderNodes = (
|
|
items: NoteFolderNode[],
|
|
groupOrderByPath: ReadonlyMap<string, number>,
|
|
): NoteFolderNode[] =>
|
|
[...items]
|
|
.sort((a, b) => {
|
|
const orderA = groupOrderByPath.get(a.path);
|
|
const orderB = groupOrderByPath.get(b.path);
|
|
if (typeof orderA === "number" && typeof orderB === "number" && orderA !== orderB) {
|
|
return orderA - orderB;
|
|
}
|
|
if (typeof orderA === "number") return -1;
|
|
if (typeof orderB === "number") return 1;
|
|
return a.name.localeCompare(b.name);
|
|
})
|
|
.map((node) => ({
|
|
...node,
|
|
children: sortFolderNodes(node.children, groupOrderByPath),
|
|
notes: sortNoteItems(node.notes),
|
|
}));
|
|
|
|
const buildNoteTree = (groups: string[], notes: VaultNote[]): { children: NoteFolderNode[]; rootNotes: VaultNote[] } => {
|
|
const nodes = new Map<string, NoteFolderNode>();
|
|
const ensureNode = (path: string): NoteFolderNode => {
|
|
const cleanPath = cleanNoteGroupPath(path);
|
|
const existing = nodes.get(cleanPath);
|
|
if (existing) return existing;
|
|
|
|
const name = cleanPath.split("/").pop() || cleanPath;
|
|
const node: NoteFolderNode = { name, path: cleanPath, children: [], notes: [] };
|
|
nodes.set(cleanPath, node);
|
|
|
|
const parentPath = cleanPath.split("/").slice(0, -1).join("/");
|
|
if (parentPath) {
|
|
ensureNode(parentPath).children.push(node);
|
|
}
|
|
return node;
|
|
};
|
|
|
|
const allGroups = normalizeNoteGroups([
|
|
...groups,
|
|
...notes.map((note) => note.group).filter((group): group is string => Boolean(group)),
|
|
]);
|
|
allGroups.flatMap(ancestorNoteGroupPaths).forEach(ensureNode);
|
|
|
|
const rootNotes: VaultNote[] = [];
|
|
notes.forEach((note) => {
|
|
const group = note.group ? cleanNoteGroupPath(note.group) : "";
|
|
if (!group) {
|
|
rootNotes.push(note);
|
|
return;
|
|
}
|
|
ensureNode(group).notes.push(note);
|
|
});
|
|
|
|
return {
|
|
children: Array.from(nodes.values()).filter((node) => !node.path.includes("/")),
|
|
rootNotes,
|
|
};
|
|
};
|
|
|
|
export const getSelectedVaultNote = (notes: VaultNote[], selectedNoteId: string | null): VaultNote | null =>
|
|
selectedNoteId ? notes.find((note) => note.id === selectedNoteId) ?? null : null;
|
|
|
|
export const isNoteFolderTreeSelected = (
|
|
selectedGroup: string | null,
|
|
selectedNoteId: string | null,
|
|
groupPath: string,
|
|
): boolean => selectedNoteId === null && selectedGroup === groupPath;
|
|
|
|
export const getNoteActionTargetGroup = (
|
|
selectedNote: VaultNote | null,
|
|
selectedGroup: string | null,
|
|
): string | null => selectedNote?.group || selectedGroup || null;
|
|
|
|
export const getNoteSelectionState = (
|
|
note: VaultNote,
|
|
isSidebarMode: boolean,
|
|
): { selectedNoteId: string; selectedGroup: null; overlayNoteId: string | null } => ({
|
|
selectedNoteId: note.id,
|
|
selectedGroup: null,
|
|
overlayNoteId: isSidebarMode ? note.id : null,
|
|
});
|
|
|
|
export const getNoteGroupSelectionState = (
|
|
groupPath: string,
|
|
): { selectedNoteId: null; selectedGroup: string; overlayNoteId: null } => ({
|
|
selectedNoteId: null,
|
|
selectedGroup: groupPath,
|
|
overlayNoteId: null,
|
|
});
|
|
|
|
export const getFallbackNoteSelectionState = (
|
|
remainingNotes: VaultNote[],
|
|
isSidebarMode: boolean,
|
|
): { selectedNoteId: string | null; selectedGroup: null; overlayNoteId: null } => ({
|
|
selectedNoteId: isSidebarMode ? null : remainingNotes[0]?.id ?? null,
|
|
selectedGroup: null,
|
|
overlayNoteId: null,
|
|
});
|
|
|
|
export const getValidatedNoteSelectionState = (
|
|
notes: VaultNote[],
|
|
selectedNoteId: string | null,
|
|
selectedGroup: string | null,
|
|
isSidebarMode: boolean,
|
|
): { selectedNoteId: string | null; selectedGroup: null; overlayNoteId: null } | null => {
|
|
if (selectedNoteId && notes.some((note) => note.id === selectedNoteId)) return null;
|
|
if (selectedNoteId || (!isSidebarMode && !selectedGroup && notes.length > 0)) {
|
|
return getFallbackNoteSelectionState(notes, isSidebarMode);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
export const getNotesGroupDropAction = (
|
|
sourceGroup: string | null,
|
|
targetGroup: string,
|
|
intent: VaultDropPosition | "inside",
|
|
): "ignore" | "inside" | "reorder" => {
|
|
if (!sourceGroup || sourceGroup === targetGroup || targetGroup.startsWith(`${sourceGroup}/`)) {
|
|
return "ignore";
|
|
}
|
|
return intent === "inside" ? "inside" : "reorder";
|
|
};
|
|
|
|
export const NotesManager: React.FC<NotesManagerProps> = ({
|
|
notes,
|
|
noteGroups,
|
|
hosts,
|
|
onUpdateNotes,
|
|
onUpdateNoteGroups,
|
|
onOpenHost,
|
|
displayMode = "full",
|
|
isActive = true,
|
|
openNoteId = null,
|
|
openNoteRequestId = null,
|
|
onOpenNoteIdHandled,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const { openExternal } = useApplicationBackend();
|
|
const isSidebarMode = displayMode === "sidebar";
|
|
const initialOpenNoteId = openNoteId && notes.some((note) => note.id === openNoteId)
|
|
? openNoteId
|
|
: null;
|
|
const [query, setQuery] = useState("");
|
|
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
|
|
const [selectedNoteId, setSelectedNoteId] = useState<string | null>(() => initialOpenNoteId ?? (isSidebarMode ? null : notes[0]?.id ?? null));
|
|
const [noteEditorMode, setNoteEditorMode] = useStoredString<NoteEditorMode>(
|
|
STORAGE_KEY_VAULT_NOTES_EDITOR_MODE,
|
|
"edit",
|
|
isNoteEditorMode,
|
|
);
|
|
const [overlayNoteId, setOverlayNoteId] = useState<string | null>(() => initialOpenNoteId);
|
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
|
|
() => new Set(notes.flatMap((note) => note.group ? ancestorNoteGroupPaths(note.group) : [])),
|
|
);
|
|
const [expandedPanel, setExpandedPanel] = useState<NotesToolbarPanel>(null);
|
|
const [creatingGroupParent, setCreatingGroupParent] = useState<string | null | undefined>(undefined);
|
|
const [editingGroupPath, setEditingGroupPath] = useState<string | null>(null);
|
|
const [editingNoteId, setEditingNoteId] = useState<string | null>(null);
|
|
const [isTreeResizing, setIsTreeResizing] = useState(false);
|
|
const [draggingNoteId, setDraggingNoteId] = useState<string | null>(null);
|
|
const [draggingGroupPath, setDraggingGroupPath] = useState<string | null>(null);
|
|
const [deleteTarget, setDeleteTarget] = useState<{
|
|
type: "note" | "group";
|
|
id: string;
|
|
name: string;
|
|
} | null>(null);
|
|
const [treeWidth, setTreeWidth, persistTreeWidth] = useStoredNumber(
|
|
STORAGE_KEY_VAULT_NOTES_TREE_WIDTH,
|
|
NOTES_TREE_DEFAULT_WIDTH,
|
|
{ min: NOTES_TREE_MIN_WIDTH, max: NOTES_TREE_MAX_WIDTH },
|
|
);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
const importFileInputRef = useRef<HTMLInputElement>(null);
|
|
const isImportingMarkdownRef = useRef(false);
|
|
const importTargetGroupRef = useRef<string | null | undefined>(undefined);
|
|
const sortedNotesRef = useRef<VaultNote[]>([]);
|
|
const activeDownloadUrlsRef = useRef<Set<string>>(new Set());
|
|
|
|
const groups = useMemo(() => normalizeNoteGroups(noteGroups), [noteGroups]);
|
|
const groupOrderByPath = useMemo(
|
|
() => new Map(groups.map((group, index) => [group, index])),
|
|
[groups],
|
|
);
|
|
const sortedNotes = useMemo(() => sortNoteItems(normalizeVaultNotes(notes)), [notes]);
|
|
sortedNotesRef.current = sortedNotes;
|
|
|
|
const commitNotes = useCallback((nextNotes: VaultNote[]) => {
|
|
const cleaned = normalizeVaultNotes(nextNotes);
|
|
sortedNotesRef.current = cleaned;
|
|
onUpdateNotes(cleaned);
|
|
return cleaned;
|
|
}, [onUpdateNotes]);
|
|
|
|
const NOTE_DRAFT_DEBOUNCE_MS = 300;
|
|
const draftNoteIdRef = useRef<string | null>(null);
|
|
const draftTitleRef = useRef<string | null>(null);
|
|
const draftContentRef = useRef<string | null>(null);
|
|
const draftTimerRef = useRef<number | null>(null);
|
|
const [draftNoteId, setDraftNoteId] = useState<string | null>(null);
|
|
const [draftTitle, setDraftTitle] = useState<string | null>(null);
|
|
|
|
const clearDraftTimer = useCallback(() => {
|
|
if (draftTimerRef.current !== null) {
|
|
window.clearTimeout(draftTimerRef.current);
|
|
draftTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const flushNoteDraft = useCallback(() => {
|
|
clearDraftTimer();
|
|
const noteId = draftNoteIdRef.current;
|
|
if (!noteId) return;
|
|
const title = draftTitleRef.current;
|
|
const content = draftContentRef.current;
|
|
draftNoteIdRef.current = null;
|
|
draftTitleRef.current = null;
|
|
draftContentRef.current = null;
|
|
setDraftNoteId(null);
|
|
setDraftTitle(null);
|
|
if (title === null && content === null) return;
|
|
commitNotes(sortedNotesRef.current.map((note) => {
|
|
if (note.id !== noteId) return note;
|
|
return {
|
|
...note,
|
|
...(title !== null ? { title } : {}),
|
|
...(content !== null ? { content } : {}),
|
|
updatedAt: Date.now(),
|
|
};
|
|
}));
|
|
}, [clearDraftTimer, commitNotes]);
|
|
|
|
const scheduleNoteDraftFlush = useCallback(() => {
|
|
clearDraftTimer();
|
|
draftTimerRef.current = window.setTimeout(() => {
|
|
draftTimerRef.current = null;
|
|
flushNoteDraft();
|
|
}, NOTE_DRAFT_DEBOUNCE_MS);
|
|
}, [clearDraftTimer, flushNoteDraft]);
|
|
|
|
const updateNoteDraft = useCallback((noteId: string, fields: { title?: string; content?: string }) => {
|
|
if (draftNoteIdRef.current && draftNoteIdRef.current !== noteId) {
|
|
flushNoteDraft();
|
|
}
|
|
draftNoteIdRef.current = noteId;
|
|
if (fields.title !== undefined) {
|
|
draftTitleRef.current = fields.title;
|
|
setDraftNoteId(noteId);
|
|
setDraftTitle(fields.title);
|
|
}
|
|
if (fields.content !== undefined) {
|
|
draftContentRef.current = fields.content;
|
|
}
|
|
scheduleNoteDraftFlush();
|
|
}, [flushNoteDraft, scheduleNoteDraftFlush]);
|
|
|
|
const flushNoteDraftRef = useRef(flushNoteDraft);
|
|
flushNoteDraftRef.current = flushNoteDraft;
|
|
|
|
useEffect(() => () => {
|
|
flushNoteDraftRef.current();
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
if (isActive) return;
|
|
flushNoteDraft();
|
|
}, [flushNoteDraft, isActive]);
|
|
|
|
useEffect(() => {
|
|
if (!isActive) return;
|
|
prefetchInlineMarkdownEditor();
|
|
}, [isActive]);
|
|
|
|
useEffect(() => {
|
|
const flushOnTeardown = () => {
|
|
flushNoteDraft();
|
|
};
|
|
const flushOnHidden = () => {
|
|
if (document.visibilityState === "hidden") flushNoteDraft();
|
|
};
|
|
window.addEventListener("pagehide", flushOnTeardown);
|
|
window.addEventListener("beforeunload", flushOnTeardown);
|
|
document.addEventListener("visibilitychange", flushOnHidden);
|
|
return () => {
|
|
window.removeEventListener("pagehide", flushOnTeardown);
|
|
window.removeEventListener("beforeunload", flushOnTeardown);
|
|
document.removeEventListener("visibilitychange", flushOnHidden);
|
|
};
|
|
}, [flushNoteDraft]);
|
|
|
|
const noteTree = useMemo(() => {
|
|
const tree = buildNoteTree(groups, sortedNotes);
|
|
return {
|
|
children: sortFolderNodes(tree.children, groupOrderByPath),
|
|
rootNotes: sortNoteItems(tree.rootNotes),
|
|
};
|
|
}, [groupOrderByPath, groups, sortedNotes]);
|
|
const selectedNote = getSelectedVaultNote(sortedNotes, selectedNoteId);
|
|
const overlayNote = sortedNotes.find((note) => note.id === overlayNoteId) ?? null;
|
|
const selectedNoteView = selectedNote && draftNoteId === selectedNote.id
|
|
? {
|
|
...selectedNote,
|
|
...(draftTitle !== null ? { title: draftTitle } : {}),
|
|
}
|
|
: selectedNote;
|
|
const overlayNoteView = overlayNote && draftNoteId === overlayNote.id
|
|
? {
|
|
...overlayNote,
|
|
...(draftTitle !== null ? { title: draftTitle } : {}),
|
|
}
|
|
: overlayNote;
|
|
|
|
useEffect(() => {
|
|
if (!draftNoteIdRef.current) return;
|
|
if (draftNoteIdRef.current === selectedNoteId || draftNoteIdRef.current === overlayNoteId) return;
|
|
flushNoteDraft();
|
|
}, [flushNoteDraft, overlayNoteId, selectedNoteId]);
|
|
|
|
useEffect(() => {
|
|
const urls = activeDownloadUrlsRef.current;
|
|
return () => {
|
|
urls.forEach((url) => URL.revokeObjectURL(url));
|
|
urls.clear();
|
|
};
|
|
}, []);
|
|
|
|
const queryText = query.trim();
|
|
const queryLower = queryText.toLowerCase();
|
|
const noteMatches = (note: VaultNote) => matchesVaultNoteSearch(note, queryText, hosts);
|
|
const groupMatches = (node: NoteFolderNode) =>
|
|
!queryLower || node.name.toLowerCase().includes(queryLower) || node.path.toLowerCase().includes(queryLower);
|
|
|
|
useEffect(() => {
|
|
const nextSelection = getValidatedNoteSelectionState(sortedNotes, selectedNoteId, selectedGroup, isSidebarMode);
|
|
if (!nextSelection) return;
|
|
setSelectedNoteId(nextSelection.selectedNoteId);
|
|
setSelectedGroup(nextSelection.selectedGroup);
|
|
setOverlayNoteId(nextSelection.overlayNoteId);
|
|
}, [isSidebarMode, selectedGroup, selectedNoteId, sortedNotes]);
|
|
|
|
useEffect(() => {
|
|
if (!overlayNoteId || sortedNotes.some((note) => note.id === overlayNoteId)) return;
|
|
setOverlayNoteId(null);
|
|
}, [overlayNoteId, sortedNotes]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedNote?.group) return;
|
|
setExpandedGroups((current) => new Set([...current, ...ancestorNoteGroupPaths(selectedNote.group || "")]));
|
|
}, [selectedNote?.group]);
|
|
|
|
useEffect(() => {
|
|
if (!openNoteId) return;
|
|
const note = sortedNotes.find((item) => item.id === openNoteId);
|
|
if (!note) return;
|
|
const nextSelection = getNoteSelectionState(note, isSidebarMode);
|
|
setSelectedNoteId(nextSelection.selectedNoteId);
|
|
setSelectedGroup(nextSelection.selectedGroup);
|
|
setOverlayNoteId(nextSelection.overlayNoteId);
|
|
if (note.group) {
|
|
setExpandedGroups((current) => new Set([...current, ...ancestorNoteGroupPaths(note.group || "")]));
|
|
}
|
|
onOpenNoteIdHandled?.();
|
|
}, [isSidebarMode, onOpenNoteIdHandled, openNoteId, openNoteRequestId, sortedNotes]);
|
|
|
|
useEffect(() => {
|
|
if (expandedPanel !== "search") return;
|
|
const frame = requestAnimationFrame(() => {
|
|
searchInputRef.current?.focus();
|
|
});
|
|
return () => cancelAnimationFrame(frame);
|
|
}, [expandedPanel]);
|
|
|
|
const expandPath = (path: string) => {
|
|
setExpandedGroups((current) => new Set([...current, ...ancestorNoteGroupPaths(path)]));
|
|
};
|
|
|
|
const toggleGroup = (path: string) => {
|
|
setExpandedGroups((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(path)) {
|
|
next.delete(path);
|
|
} else {
|
|
next.add(path);
|
|
}
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const allGroupPaths = useMemo(() => {
|
|
const paths: string[] = [];
|
|
const visit = (nodes: NoteFolderNode[]) => {
|
|
nodes.forEach((node) => {
|
|
paths.push(node.path);
|
|
visit(node.children);
|
|
});
|
|
};
|
|
visit(noteTree.children);
|
|
return paths;
|
|
}, [noteTree.children]);
|
|
|
|
const expandAllGroups = () => setExpandedGroups(new Set(allGroupPaths));
|
|
const collapseAllGroups = () => setExpandedGroups(new Set());
|
|
|
|
/** Flush pending draft, then mutate from the post-flush ref snapshot. */
|
|
const commitNotesAfterFlush = useCallback((mutator: (notes: VaultNote[]) => VaultNote[]) => {
|
|
flushNoteDraft();
|
|
return commitNotes(mutator(sortedNotesRef.current));
|
|
}, [commitNotes, flushNoteDraft]);
|
|
|
|
/** Rename from the tree row: merge title into the post-flush note so an
|
|
* unflushed body edit is not discarded by a stale tree-row snapshot. */
|
|
const renameNoteFromTree = (noteId: string, title: string) => {
|
|
const nextTitle = title.trim();
|
|
const updatedAt = Date.now();
|
|
commitNotesAfterFlush((notes) => notes.map((note) => (
|
|
note.id === noteId ? { ...note, title: nextTitle, updatedAt } : note
|
|
)));
|
|
};
|
|
|
|
const saveNoteTitleDraft = (note: VaultNote, title: string) => {
|
|
updateNoteDraft(note.id, { title });
|
|
};
|
|
|
|
/** Ref-only title stash for IME composition — avoids controlled value rewrite. */
|
|
const stashNoteTitleDraft = (note: VaultNote, title: string) => {
|
|
if (draftNoteIdRef.current && draftNoteIdRef.current !== note.id) {
|
|
flushNoteDraft();
|
|
}
|
|
// Cancel any idle-commit debounce so a prior ASCII commit cannot flush
|
|
// mid-composition and rewrite the controlled title.
|
|
clearDraftTimer();
|
|
draftNoteIdRef.current = note.id;
|
|
draftTitleRef.current = title;
|
|
};
|
|
|
|
const saveNoteContentDraft = useCallback((noteId: string, content: string) => {
|
|
updateNoteDraft(noteId, { content });
|
|
}, [updateNoteDraft]);
|
|
|
|
const handleOpenHostFromNote = useCallback((host: Host, noteId: string) => {
|
|
onOpenHost?.(host, { noteId });
|
|
}, [onOpenHost]);
|
|
|
|
const renderNoteModeToggle = () => {
|
|
const label = noteEditorMode === "edit" ? t("notes.mode.preview") : t("notes.mode.edit");
|
|
const Icon = noteEditorMode === "edit" ? Glasses : PencilLine;
|
|
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
data-note-mode-switch
|
|
aria-label={label}
|
|
className="app-no-drag h-8 w-8 shrink-0 rounded-md p-0 text-muted-foreground transition-colors hover:bg-secondary/70 hover:text-foreground"
|
|
onClick={() => {
|
|
flushNoteDraft();
|
|
setNoteEditorMode((currentMode) => (
|
|
currentMode === "edit" ? "preview" : "edit"
|
|
));
|
|
}}
|
|
>
|
|
<Icon size={16} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">{label}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
};
|
|
|
|
const addNoteToGroup = (group: string | null) => {
|
|
let created: VaultNote | null = null;
|
|
commitNotesAfterFlush((notes) => {
|
|
created = createNote(group, getNextVaultOrder(notes));
|
|
return [...notes, created];
|
|
});
|
|
if (!created) return;
|
|
if (group) expandPath(group);
|
|
const nextSelection = getNoteSelectionState(created, isSidebarMode);
|
|
setSelectedNoteId(nextSelection.selectedNoteId);
|
|
setSelectedGroup(nextSelection.selectedGroup);
|
|
setOverlayNoteId(nextSelection.overlayNoteId);
|
|
};
|
|
|
|
const addNote = () => {
|
|
addNoteToGroup(getNoteActionTargetGroup(selectedNote, selectedGroup));
|
|
};
|
|
|
|
const openImportMarkdownPicker = useCallback((targetGroupOverride?: string | null) => {
|
|
importTargetGroupRef.current = targetGroupOverride;
|
|
importFileInputRef.current?.click();
|
|
}, []);
|
|
|
|
const handleImportMarkdownFiles = useCallback(async (fileList: FileList | null) => {
|
|
const resetImportInput = () => {
|
|
if (importFileInputRef.current) {
|
|
importFileInputRef.current.value = "";
|
|
}
|
|
};
|
|
|
|
if (!fileList || fileList.length === 0) {
|
|
resetImportInput();
|
|
return;
|
|
}
|
|
if (isImportingMarkdownRef.current) {
|
|
toast.info(t("notes.import.toast.inProgress"));
|
|
resetImportInput();
|
|
return;
|
|
}
|
|
|
|
const files = Array.from(fileList);
|
|
const markdownFiles = files.filter((file) => /\.(md|markdown|txt)$/i.test(file.name));
|
|
const skippedCount = files.length - markdownFiles.length;
|
|
const pendingTargetGroup = importTargetGroupRef.current;
|
|
importTargetGroupRef.current = undefined;
|
|
const targetGroup = pendingTargetGroup !== undefined
|
|
? pendingTargetGroup
|
|
: getNoteActionTargetGroup(selectedNote, selectedGroup);
|
|
isImportingMarkdownRef.current = true;
|
|
|
|
try {
|
|
if (markdownFiles.length === 0) {
|
|
toast.error(t("notes.import.toast.noNotes"));
|
|
return;
|
|
}
|
|
|
|
const payloads = await Promise.all(
|
|
markdownFiles.map(async (file) => ({
|
|
fileName: file.name,
|
|
content: await readTextFile(file),
|
|
})),
|
|
);
|
|
|
|
const result = importMarkdownPayloadsToVaultNotes(
|
|
payloads,
|
|
(() => {
|
|
flushNoteDraft();
|
|
return sortedNotesRef.current;
|
|
})(),
|
|
targetGroup,
|
|
);
|
|
|
|
if (result.importedCount === 0) {
|
|
toast.error(t("notes.import.toast.noNotes"));
|
|
return;
|
|
}
|
|
|
|
const mergedNotes = commitNotes(result.notes);
|
|
if (targetGroup) expandPath(targetGroup);
|
|
|
|
const lastImported = mergedNotes[mergedNotes.length - 1];
|
|
const nextSelection = getNoteSelectionState(lastImported, isSidebarMode);
|
|
setSelectedNoteId(nextSelection.selectedNoteId);
|
|
setSelectedGroup(nextSelection.selectedGroup);
|
|
setOverlayNoteId(nextSelection.overlayNoteId);
|
|
|
|
toast.success(t("notes.import.toast.success", { count: result.importedCount }));
|
|
if (skippedCount > 0) {
|
|
toast.info(t("notes.import.toast.skipped", { count: skippedCount }));
|
|
}
|
|
} catch (err) {
|
|
logger.error("Failed to import markdown files:", err);
|
|
toast.error(t("notes.import.toast.failed"));
|
|
} finally {
|
|
isImportingMarkdownRef.current = false;
|
|
resetImportInput();
|
|
}
|
|
}, [
|
|
isSidebarMode,
|
|
commitNotes,
|
|
flushNoteDraft,
|
|
selectedGroup,
|
|
selectedNote,
|
|
t,
|
|
]);
|
|
|
|
const downloadNotesBlob = useCallback((blob: Blob, fileName: string) => {
|
|
const url = URL.createObjectURL(blob);
|
|
activeDownloadUrlsRef.current.add(url);
|
|
const anchor = document.createElement("a");
|
|
anchor.href = url;
|
|
anchor.download = fileName;
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
document.body.removeChild(anchor);
|
|
|
|
window.setTimeout(() => {
|
|
URL.revokeObjectURL(url);
|
|
activeDownloadUrlsRef.current.delete(url);
|
|
}, 60_000);
|
|
}, []);
|
|
|
|
const exportNoteToMarkdown = useCallback((note: VaultNote) => {
|
|
flushNoteDraft();
|
|
const latest = sortedNotesRef.current.find((item) => item.id === note.id) ?? note;
|
|
try {
|
|
const fileName = `${sanitizeNoteExportFileNamePart(latest.title, "note")}.md`;
|
|
downloadNotesBlob(
|
|
new Blob([latest.content], { type: "text/markdown;charset=utf-8" }),
|
|
fileName,
|
|
);
|
|
toast.success(t("notes.export.toast.success", { count: 1 }));
|
|
} catch (err) {
|
|
logger.error("Failed to export note:", err);
|
|
toast.error(t("notes.export.toast.failed"));
|
|
}
|
|
}, [downloadNotesBlob, flushNoteDraft, t]);
|
|
|
|
const exportNotesToZip = useCallback((scope: VaultNotesExportScope, fileNamePart: string) => {
|
|
flushNoteDraft();
|
|
try {
|
|
const files = buildVaultNoteMarkdownExportFiles(sortedNotesRef.current, scope);
|
|
if (files.length === 0) {
|
|
toast.warning(t("notes.export.toast.empty"));
|
|
return;
|
|
}
|
|
|
|
const blob = buildTextFilesZipBlob(files);
|
|
const safeName = sanitizeNoteExportFileNamePart(fileNamePart, "notes");
|
|
downloadNotesBlob(blob, `netcatty-notes-${safeName}.zip`);
|
|
toast.success(t("notes.export.toast.success", { count: files.length }));
|
|
} catch (err) {
|
|
logger.error("Failed to export notes:", err);
|
|
toast.error(t("notes.export.toast.failed"));
|
|
}
|
|
}, [downloadNotesBlob, flushNoteDraft, t]);
|
|
|
|
const exportAllNotes = useCallback(() => {
|
|
exportNotesToZip({ type: "all" }, "all");
|
|
}, [exportNotesToZip]);
|
|
|
|
const exportGroupNotes = useCallback((groupPath: string) => {
|
|
exportNotesToZip({ type: "group", group: groupPath }, groupPath);
|
|
}, [exportNotesToZip]);
|
|
|
|
const renderNoteExportButton = (note: VaultNote) => (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label={t("notes.action.exportNote")}
|
|
className="app-no-drag h-8 w-8 shrink-0 rounded-md p-0 text-muted-foreground transition-colors hover:bg-secondary/70 hover:text-foreground"
|
|
onClick={() => exportNoteToMarkdown(note)}
|
|
>
|
|
<Download size={16} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">{t("notes.action.exportNote")}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
|
|
const duplicateNoteById = (noteId: string) => {
|
|
flushNoteDraft();
|
|
const source = sortedNotesRef.current.find((note) => note.id === noteId);
|
|
if (!source) return;
|
|
const now = Date.now();
|
|
const copy: VaultNote = {
|
|
...source,
|
|
id: crypto.randomUUID(),
|
|
title: `${source.title} (${t("action.copy")})`,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
order: getNextVaultOrder(sortedNotesRef.current),
|
|
};
|
|
commitNotes([...sortedNotesRef.current, copy]);
|
|
if (copy.group) expandPath(copy.group);
|
|
const nextSelection = getNoteSelectionState(copy, isSidebarMode);
|
|
setSelectedNoteId(nextSelection.selectedNoteId);
|
|
setSelectedGroup(nextSelection.selectedGroup);
|
|
setOverlayNoteId(nextSelection.overlayNoteId);
|
|
};
|
|
|
|
const performDeleteNoteById = (noteId: string) => {
|
|
const next = commitNotesAfterFlush((notes) => notes.filter((note) => note.id !== noteId));
|
|
if (selectedNoteId === noteId) {
|
|
const nextSelection = getFallbackNoteSelectionState(next, isSidebarMode);
|
|
setSelectedNoteId(nextSelection.selectedNoteId);
|
|
setSelectedGroup(nextSelection.selectedGroup);
|
|
setOverlayNoteId(nextSelection.overlayNoteId);
|
|
setEditingNoteId(null);
|
|
}
|
|
if (overlayNoteId === noteId) setOverlayNoteId(null);
|
|
};
|
|
|
|
const requestDeleteNoteById = (noteId: string) => {
|
|
const note = sortedNotes.find((item) => item.id === noteId);
|
|
setDeleteTarget({
|
|
type: "note",
|
|
id: noteId,
|
|
name: note?.title || t("notes.title.placeholder"),
|
|
});
|
|
};
|
|
|
|
const startCreateGroup = () => {
|
|
const targetGroup = getNoteActionTargetGroup(selectedNote, selectedGroup);
|
|
setCreatingGroupParent(targetGroup);
|
|
if (targetGroup) expandPath(targetGroup);
|
|
};
|
|
|
|
const commitCreateGroup = (name: string) => {
|
|
const nextPath = joinNoteGroupPath(creatingGroupParent ?? null, name);
|
|
setCreatingGroupParent(undefined);
|
|
if (!nextPath) return;
|
|
|
|
const next = normalizeNoteGroups([...groups, ...ancestorNoteGroupPaths(nextPath)]);
|
|
onUpdateNoteGroups(next);
|
|
expandPath(nextPath);
|
|
const nextSelection = getNoteGroupSelectionState(nextPath);
|
|
setSelectedNoteId(nextSelection.selectedNoteId);
|
|
setSelectedGroup(nextSelection.selectedGroup);
|
|
setOverlayNoteId(nextSelection.overlayNoteId);
|
|
};
|
|
|
|
const renameGroup = (group: string, nextName: string) => {
|
|
setEditingGroupPath(null);
|
|
const nextPath = joinNoteGroupPath(getNoteGroupParentPath(group), nextName);
|
|
if (!nextPath || nextPath === group) return;
|
|
|
|
const nextGroups = normalizeNoteGroups(
|
|
groups.map((item) => replaceNoteGroupPrefix(item, group, nextPath) || ""),
|
|
);
|
|
onUpdateNoteGroups(nextGroups);
|
|
commitNotesAfterFlush((notes) => notes.map((note) => ({
|
|
...note,
|
|
group: replaceNoteGroupPrefix(note.group, group, nextPath),
|
|
})));
|
|
setExpandedGroups((current) => {
|
|
const next = new Set<string>();
|
|
current.forEach((item) => {
|
|
const renamed = replaceNoteGroupPrefix(item, group, nextPath);
|
|
if (renamed) next.add(renamed);
|
|
});
|
|
ancestorNoteGroupPaths(nextPath).forEach((path) => next.add(path));
|
|
return next;
|
|
});
|
|
if (selectedGroup && isNoteGroupInside(selectedGroup, group)) {
|
|
setSelectedGroup(replaceNoteGroupPrefix(selectedGroup, group, nextPath) ?? null);
|
|
}
|
|
};
|
|
|
|
const performDeleteGroup = (group: string) => {
|
|
onUpdateNoteGroups(groups.filter((item) => !isNoteGroupInside(item, group)));
|
|
commitNotesAfterFlush((notes) => notes.map((note) => (
|
|
isNoteGroupInside(note.group, group) ? { ...note, group: undefined } : note
|
|
)));
|
|
if (selectedGroup && isNoteGroupInside(selectedGroup, group)) setSelectedGroup(null);
|
|
setEditingGroupPath(null);
|
|
};
|
|
|
|
const requestDeleteGroup = (group: string) => {
|
|
setDeleteTarget({
|
|
type: "group",
|
|
id: group,
|
|
name: group,
|
|
});
|
|
};
|
|
|
|
const confirmDeleteTarget = () => {
|
|
if (!deleteTarget) return;
|
|
if (deleteTarget.type === "note") {
|
|
performDeleteNoteById(deleteTarget.id);
|
|
} else {
|
|
performDeleteGroup(deleteTarget.id);
|
|
}
|
|
setDeleteTarget(null);
|
|
};
|
|
|
|
const resetTreeDragState = () => {
|
|
setDraggingNoteId(null);
|
|
setDraggingGroupPath(null);
|
|
clearVaultDropIndicator();
|
|
};
|
|
|
|
const getDraggedNoteId = (dataTransfer: DataTransfer) =>
|
|
dataTransfer.getData(NOTE_DRAG_TYPE) || dataTransfer.getData("note-id");
|
|
|
|
const getDraggedGroupPath = (dataTransfer: DataTransfer) =>
|
|
dataTransfer.getData(NOTE_GROUP_DRAG_TYPE) || dataTransfer.getData("note-group-path");
|
|
|
|
const hasNotesTreeDrag = (dataTransfer: DataTransfer) =>
|
|
draggingNoteId
|
|
|| draggingGroupPath
|
|
|| hasVaultDragType(dataTransfer, NOTE_DRAG_TYPE)
|
|
|| hasVaultDragType(dataTransfer, NOTE_GROUP_DRAG_TYPE)
|
|
|| hasVaultDragType(dataTransfer, "note-id")
|
|
|| hasVaultDragType(dataTransfer, "note-group-path");
|
|
|
|
const handleTreeRowDragLeave = (event: React.DragEvent<HTMLElement>) => {
|
|
const relatedTarget = event.relatedTarget;
|
|
if (relatedTarget instanceof Node && event.currentTarget.contains(relatedTarget)) return;
|
|
clearVaultDropIndicator();
|
|
};
|
|
|
|
const moveNoteToGroup = (noteId: string, group: string | null) => {
|
|
flushNoteDraft();
|
|
const source = sortedNotesRef.current.find((note) => note.id === noteId);
|
|
if (!source) return;
|
|
const nextGroup = group || undefined;
|
|
if ((source.group || undefined) === nextGroup) return;
|
|
|
|
commitNotes(sortedNotesRef.current.map((note) => (
|
|
note.id === noteId ? { ...note, group: nextGroup, updatedAt: Date.now() } : note
|
|
)));
|
|
if (group) expandPath(group);
|
|
};
|
|
|
|
const reorderNoteToNote = (sourceId: string, targetNote: VaultNote, event: React.DragEvent<HTMLElement>) => {
|
|
if (!sourceId || sourceId === targetNote.id) return;
|
|
const position = getVaultDropPosition(event.currentTarget, event.clientX, event.clientY);
|
|
commitNotesAfterFlush((notes) => {
|
|
const movedNotes = notes.map((note) => (
|
|
note.id === sourceId
|
|
? { ...note, group: targetNote.group, updatedAt: Date.now() }
|
|
: note
|
|
));
|
|
return reorderVaultItems(movedNotes, sourceId, targetNote.id, position);
|
|
});
|
|
if (targetNote.group) expandPath(targetNote.group);
|
|
};
|
|
|
|
const moveGroupToParent = (group: string, parent: string | null) => {
|
|
flushNoteDraft();
|
|
const knownGroups = normalizeNoteGroups([
|
|
...groups,
|
|
...sortedNotesRef.current.map((note) => note.group).filter((item): item is string => Boolean(item)),
|
|
]);
|
|
const nextPath = resolveMovedNoteGroupPath(group, parent, knownGroups);
|
|
if (!nextPath) return;
|
|
const nextGroups = normalizeNoteGroups(groups.map((item) => replaceNoteGroupPrefix(item, group, nextPath) || ""));
|
|
onUpdateNoteGroups(nextGroups);
|
|
commitNotes(sortedNotesRef.current.map((note) => ({
|
|
...note,
|
|
group: replaceNoteGroupPrefix(note.group, group, nextPath),
|
|
})));
|
|
setExpandedGroups((current) => remapExpandedNoteGroupPaths(current, group, nextPath));
|
|
if (selectedGroup && isNoteGroupInside(selectedGroup, group)) {
|
|
setSelectedGroup(replaceNoteGroupPrefix(selectedGroup, group, nextPath) ?? null);
|
|
}
|
|
};
|
|
|
|
const reorderGroupToGroup = (
|
|
sourceGroup: string,
|
|
targetGroup: string,
|
|
position: VaultDropPosition,
|
|
) => {
|
|
if (!sourceGroup || !targetGroup || sourceGroup === targetGroup) return;
|
|
if (targetGroup.startsWith(`${sourceGroup}/`)) return;
|
|
|
|
flushNoteDraft();
|
|
const targetParent = getNoteGroupParentPath(targetGroup);
|
|
const knownGroups = normalizeNoteGroups([
|
|
...groups,
|
|
...sortedNotesRef.current.map((note) => note.group).filter((item): item is string => Boolean(item)),
|
|
]);
|
|
const nextSourceGroup = getNoteGroupParentPath(sourceGroup) === targetParent
|
|
? cleanNoteGroupPath(sourceGroup)
|
|
: resolveMovedNoteGroupPath(sourceGroup, targetParent, knownGroups);
|
|
if (!nextSourceGroup) return;
|
|
|
|
const nextGroupsBeforeReorder = normalizeNoteGroups([
|
|
...groups.map((item) => replaceNoteGroupPrefix(item, sourceGroup, nextSourceGroup) || ""),
|
|
...ancestorNoteGroupPaths(nextSourceGroup),
|
|
...ancestorNoteGroupPaths(targetGroup),
|
|
]);
|
|
const nextGroups = reorderVaultStrings(
|
|
nextGroupsBeforeReorder,
|
|
nextSourceGroup,
|
|
targetGroup,
|
|
position,
|
|
);
|
|
|
|
onUpdateNoteGroups(nextGroups);
|
|
if (nextSourceGroup !== sourceGroup) {
|
|
commitNotes(sortedNotesRef.current.map((note) => ({
|
|
...note,
|
|
group: replaceNoteGroupPrefix(note.group, sourceGroup, nextSourceGroup),
|
|
})));
|
|
setExpandedGroups((current) => remapExpandedNoteGroupPaths(current, sourceGroup, nextSourceGroup));
|
|
if (selectedGroup && isNoteGroupInside(selectedGroup, sourceGroup)) {
|
|
setSelectedGroup(replaceNoteGroupPrefix(selectedGroup, sourceGroup, nextSourceGroup) ?? null);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleGroupDrop = (targetGroup: string | null, event: React.DragEvent) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const noteId = getDraggedNoteId(event.dataTransfer);
|
|
const groupPath = getDraggedGroupPath(event.dataTransfer);
|
|
if (noteId) moveNoteToGroup(noteId, targetGroup);
|
|
if (groupPath) moveGroupToParent(groupPath, targetGroup);
|
|
resetTreeDragState();
|
|
};
|
|
|
|
const renderNoteActions = (note: VaultNote, mode: "dropdown" | "context", closeMenu?: () => void) => {
|
|
const actions = [
|
|
{
|
|
label: t("common.rename"),
|
|
action: () => setEditingNoteId(note.id),
|
|
},
|
|
{
|
|
label: t("action.copy"),
|
|
action: () => duplicateNoteById(note.id),
|
|
},
|
|
{
|
|
label: t("notes.action.exportNote"),
|
|
action: () => exportNoteToMarkdown(note),
|
|
},
|
|
{
|
|
label: t("action.delete"),
|
|
action: () => requestDeleteNoteById(note.id),
|
|
destructive: true,
|
|
},
|
|
];
|
|
|
|
if (mode === "context") {
|
|
return actions.map((action) => (
|
|
<ContextMenuItem
|
|
key={action.label}
|
|
className={action.destructive ? "text-destructive focus:text-destructive" : undefined}
|
|
onSelect={() => {
|
|
action.action();
|
|
}}
|
|
>
|
|
{action.label}
|
|
</ContextMenuItem>
|
|
));
|
|
}
|
|
|
|
return actions.map((action) => (
|
|
<button
|
|
key={action.label}
|
|
type="button"
|
|
className={cn(menuItemClass, action.destructive && "text-destructive hover:bg-destructive/10")}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
closeMenu?.();
|
|
action.action();
|
|
}}
|
|
>
|
|
{action.label}
|
|
</button>
|
|
));
|
|
};
|
|
|
|
const renderGroupActions = (groupPath: string, mode: "dropdown" | "context", closeMenu?: () => void) => {
|
|
const actions = [
|
|
{
|
|
label: t("notes.action.newNote"),
|
|
action: () => addNoteToGroup(groupPath),
|
|
},
|
|
{
|
|
label: t("notes.action.newGroup"),
|
|
action: () => {
|
|
setCreatingGroupParent(groupPath);
|
|
expandPath(groupPath);
|
|
},
|
|
},
|
|
{
|
|
label: t("notes.action.importMarkdown"),
|
|
action: () => openImportMarkdownPicker(groupPath),
|
|
},
|
|
{
|
|
label: t("notes.action.exportGroup"),
|
|
action: () => exportGroupNotes(groupPath),
|
|
},
|
|
{
|
|
label: t("common.rename"),
|
|
action: () => setEditingGroupPath(groupPath),
|
|
},
|
|
{
|
|
label: t("action.delete"),
|
|
action: () => requestDeleteGroup(groupPath),
|
|
destructive: true,
|
|
},
|
|
];
|
|
|
|
if (mode === "context") {
|
|
return actions.map((action) => (
|
|
<ContextMenuItem
|
|
key={action.label}
|
|
className={action.destructive ? "text-destructive focus:text-destructive" : undefined}
|
|
onSelect={() => {
|
|
action.action();
|
|
}}
|
|
>
|
|
{action.label}
|
|
</ContextMenuItem>
|
|
));
|
|
}
|
|
|
|
return actions.map((action) => (
|
|
<button
|
|
key={action.label}
|
|
type="button"
|
|
className={cn(menuItemClass, action.destructive && "text-destructive hover:bg-destructive/10")}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
closeMenu?.();
|
|
action.action();
|
|
}}
|
|
>
|
|
{action.label}
|
|
</button>
|
|
));
|
|
};
|
|
|
|
const renderCreateGroupRow = (parent: string | null, depth: number) => {
|
|
if (creatingGroupParent !== parent) return null;
|
|
return (
|
|
<div
|
|
key={`new-folder-${parent || "root"}`}
|
|
className="flex h-7 items-center px-2 text-sm"
|
|
style={{ paddingLeft: depth * 16 + 4 }}
|
|
>
|
|
<div className="mr-1 h-5 w-4 shrink-0" />
|
|
<div className="mr-2 flex h-5 w-5 shrink-0 items-center justify-center text-current">
|
|
<Folder size={16} strokeWidth={1.9} />
|
|
</div>
|
|
<VaultTreeInlineRenameInput
|
|
initialName={t("notes.action.newGroup")}
|
|
onCommit={commitCreateGroup}
|
|
onCancel={() => setCreatingGroupParent(undefined)}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const noteDisplayTitle = (title: string) => title || t("notes.title.placeholder");
|
|
|
|
const renderNoteRow = (note: VaultNote, depth: number) => {
|
|
if (!noteMatches(note)) return null;
|
|
return (
|
|
<ContextMenu key={note.id}>
|
|
<ContextMenuTrigger asChild>
|
|
<VaultTreeItemRow
|
|
label={noteDisplayTitle(note.title)}
|
|
depth={depth}
|
|
selected={selectedNoteId === note.id}
|
|
editing={editingNoteId === note.id}
|
|
editingInitialName={note.title}
|
|
onRenameCommit={(name) => {
|
|
setEditingNoteId(null);
|
|
renameNoteFromTree(note.id, name);
|
|
}}
|
|
onRenameCancel={() => setEditingNoteId(null)}
|
|
icon={<FileText size={16} className="mr-2 shrink-0 text-muted-foreground" />}
|
|
data-note-id={note.id}
|
|
data-notes-drag-kind="note"
|
|
data-notes-context-menu="note"
|
|
data-vault-reorder-dragging={draggingNoteId === note.id ? "true" : undefined}
|
|
draggable={editingNoteId !== note.id}
|
|
onDragStart={(event) => {
|
|
event.dataTransfer.setData(NOTE_DRAG_TYPE, note.id);
|
|
event.dataTransfer.setData("note-id", note.id);
|
|
event.dataTransfer.effectAllowed = "move";
|
|
setDraggingNoteId(note.id);
|
|
}}
|
|
onDragOver={(event) => {
|
|
const sourceNoteId = draggingNoteId || getDraggedNoteId(event.dataTransfer);
|
|
if (!sourceNoteId || sourceNoteId === note.id) {
|
|
clearVaultDropIndicator();
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
event.dataTransfer.dropEffect = "move";
|
|
markVaultDropIndicator(
|
|
event.currentTarget,
|
|
getVaultDropPosition(event.currentTarget, event.clientX, event.clientY),
|
|
);
|
|
}}
|
|
onDragLeave={handleTreeRowDragLeave}
|
|
onDrop={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
reorderNoteToNote(draggingNoteId || getDraggedNoteId(event.dataTransfer), note, event);
|
|
resetTreeDragState();
|
|
}}
|
|
onDragEnd={resetTreeDragState}
|
|
onClick={() => {
|
|
const nextSelection = getNoteSelectionState(note, isSidebarMode);
|
|
setSelectedNoteId(nextSelection.selectedNoteId);
|
|
setSelectedGroup(nextSelection.selectedGroup);
|
|
setOverlayNoteId(nextSelection.overlayNoteId);
|
|
}}
|
|
actions={(
|
|
<HoverActionMenu>
|
|
{(closeMenu) => renderNoteActions(note, "dropdown", closeMenu)}
|
|
</HoverActionMenu>
|
|
)}
|
|
/>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent data-notes-context-menu="note">
|
|
{renderNoteActions(note, "context")}
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
};
|
|
|
|
const renderFolderRow = (node: NoteFolderNode, depth: number): React.ReactNode => {
|
|
const folderMatchesQuery = groupMatches(node);
|
|
const visibleNotes = folderMatchesQuery ? node.notes : node.notes.filter(noteMatches);
|
|
const visibleChildren = node.children
|
|
.map((child) => renderFolderRow(child, depth + 1))
|
|
.filter(Boolean);
|
|
if (queryText && !folderMatchesQuery && visibleNotes.length === 0 && visibleChildren.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const expanded = queryText ? true : expandedGroups.has(node.path);
|
|
const hasChildren = node.children.length > 0 || node.notes.length > 0;
|
|
return (
|
|
<React.Fragment key={node.path}>
|
|
<ContextMenu>
|
|
<ContextMenuTrigger asChild>
|
|
<VaultTreeGroupRow
|
|
name={node.name}
|
|
depth={depth}
|
|
expanded={expanded}
|
|
selected={isNoteFolderTreeSelected(selectedGroup, selectedNoteId, node.path)}
|
|
hasChildren={hasChildren}
|
|
iconSize={16}
|
|
editing={editingGroupPath === node.path}
|
|
editingInitialName={node.name}
|
|
onRenameCommit={(name) => renameGroup(node.path, name)}
|
|
onRenameCancel={() => setEditingGroupPath(null)}
|
|
data-note-group-path={node.path}
|
|
data-notes-drag-kind="group"
|
|
data-notes-context-menu="group"
|
|
data-vault-reorder-dragging={draggingGroupPath === node.path ? "true" : undefined}
|
|
draggable={editingGroupPath !== node.path}
|
|
onDragStart={(event) => {
|
|
event.dataTransfer.setData(NOTE_GROUP_DRAG_TYPE, node.path);
|
|
event.dataTransfer.setData("note-group-path", node.path);
|
|
event.dataTransfer.effectAllowed = "move";
|
|
setDraggingGroupPath(node.path);
|
|
}}
|
|
onDragOver={(event) => {
|
|
const sourceNoteId = draggingNoteId || getDraggedNoteId(event.dataTransfer);
|
|
const sourceGroupPath = draggingGroupPath || getDraggedGroupPath(event.dataTransfer);
|
|
if (!sourceNoteId && !sourceGroupPath) {
|
|
clearVaultDropIndicator();
|
|
return;
|
|
}
|
|
if (sourceGroupPath && (sourceGroupPath === node.path || node.path.startsWith(`${sourceGroupPath}/`))) {
|
|
clearVaultDropIndicator();
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
event.dataTransfer.dropEffect = "move";
|
|
if (sourceGroupPath) {
|
|
const intent = getVaultDropIntent(event.currentTarget, event.clientX, event.clientY, false);
|
|
if (intent === "inside") {
|
|
markVaultInsideDropIndicator(event.currentTarget);
|
|
} else {
|
|
markVaultDropIndicator(event.currentTarget, intent);
|
|
}
|
|
return;
|
|
}
|
|
markVaultInsideDropIndicator(event.currentTarget);
|
|
}}
|
|
onDragLeave={handleTreeRowDragLeave}
|
|
onDrop={(event) => {
|
|
const sourceGroupPath = draggingGroupPath || getDraggedGroupPath(event.dataTransfer);
|
|
if (sourceGroupPath) {
|
|
const intent = getVaultDropIntent(event.currentTarget, event.clientX, event.clientY, false);
|
|
const dropAction = getNotesGroupDropAction(sourceGroupPath, node.path, intent);
|
|
if (dropAction === "reorder" && intent !== "inside") {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
reorderGroupToGroup(sourceGroupPath, node.path, intent);
|
|
resetTreeDragState();
|
|
return;
|
|
}
|
|
if (dropAction === "ignore") return;
|
|
}
|
|
handleGroupDrop(node.path, event);
|
|
}}
|
|
onDragEnd={resetTreeDragState}
|
|
onClick={() => {
|
|
const nextSelection = getNoteGroupSelectionState(node.path);
|
|
setSelectedNoteId(nextSelection.selectedNoteId);
|
|
setSelectedGroup(nextSelection.selectedGroup);
|
|
setOverlayNoteId(nextSelection.overlayNoteId);
|
|
if (hasChildren) toggleGroup(node.path);
|
|
}}
|
|
actions={(
|
|
<HoverActionMenu>
|
|
{(closeMenu) => renderGroupActions(node.path, "dropdown", closeMenu)}
|
|
</HoverActionMenu>
|
|
)}
|
|
/>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent data-notes-context-menu="group">
|
|
{renderGroupActions(node.path, "context")}
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
{expanded && (
|
|
<>
|
|
{renderCreateGroupRow(node.path, depth + 1)}
|
|
{visibleChildren}
|
|
{visibleNotes.map((note) => renderNoteRow(note, depth + 1))}
|
|
</>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
};
|
|
|
|
const visibleRootNotes = noteTree.rootNotes.filter(noteMatches);
|
|
const visibleTree = noteTree.children
|
|
.map((child) => renderFolderRow(child, 0))
|
|
.filter(Boolean);
|
|
const treeIsEmpty = visibleRootNotes.length === 0 && visibleTree.length === 0;
|
|
const hasSearch = query.trim().length > 0;
|
|
const canExpandCollapse = allGroupPaths.length > 0 && !hasSearch;
|
|
const shouldShowNotesTree = isSidebarMode || sortedNotes.length > 0;
|
|
|
|
const handleTreeResizeStart = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const startX = event.clientX;
|
|
const startWidth = treeWidth;
|
|
const previousCursor = document.body.style.cursor;
|
|
const previousUserSelect = document.body.style.userSelect;
|
|
|
|
setIsTreeResizing(true);
|
|
document.body.style.cursor = "col-resize";
|
|
document.body.style.userSelect = "none";
|
|
|
|
const clampWidth = (value: number) =>
|
|
Math.max(NOTES_TREE_MIN_WIDTH, Math.min(NOTES_TREE_MAX_WIDTH, value));
|
|
|
|
const handlePointerMove = (moveEvent: PointerEvent) => {
|
|
setTreeWidth(clampWidth(startWidth + moveEvent.clientX - startX));
|
|
};
|
|
|
|
const handlePointerUp = (upEvent: PointerEvent) => {
|
|
const nextWidth = clampWidth(startWidth + upEvent.clientX - startX);
|
|
setTreeWidth(nextWidth);
|
|
persistTreeWidth(nextWidth);
|
|
setIsTreeResizing(false);
|
|
document.body.style.cursor = previousCursor;
|
|
document.body.style.userSelect = previousUserSelect;
|
|
window.removeEventListener("pointermove", handlePointerMove);
|
|
window.removeEventListener("pointerup", handlePointerUp);
|
|
window.removeEventListener("pointercancel", handlePointerUp);
|
|
};
|
|
|
|
window.addEventListener("pointermove", handlePointerMove);
|
|
window.addEventListener("pointerup", handlePointerUp);
|
|
window.addEventListener("pointercancel", handlePointerUp);
|
|
}, [persistTreeWidth, setTreeWidth, treeWidth]);
|
|
|
|
return (
|
|
<div className="relative flex h-full min-h-0 flex-col overflow-hidden">
|
|
<input
|
|
ref={importFileInputRef}
|
|
type="file"
|
|
accept=".md,.markdown,.txt"
|
|
multiple
|
|
className="hidden"
|
|
onChange={(event) => {
|
|
void handleImportMarkdownFiles(event.target.files);
|
|
}}
|
|
/>
|
|
<div className="flex min-h-0 flex-1">
|
|
{shouldShowNotesTree && (
|
|
<aside
|
|
className={cn(
|
|
"relative flex flex-col bg-background",
|
|
isSidebarMode ? "min-w-0 flex-1" : "shrink-0 border-r border-border/60",
|
|
)}
|
|
style={isSidebarMode ? undefined : { width: treeWidth }}
|
|
>
|
|
<div className="flex-shrink-0">
|
|
<div className={cn(
|
|
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
|
|
"flex items-center gap-1 border-b border-border/60 px-1.5",
|
|
)}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={toolbarIconButtonClass}
|
|
onClick={() => setExpandedPanel(expandedPanel === "search" ? null : "search")}
|
|
>
|
|
<Search size={14} className={expandedPanel === "search" || hasSearch ? "text-foreground" : "text-muted-foreground"} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">{t("notes.search.placeholder")}</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={toolbarIconButtonClass}
|
|
onClick={addNote}
|
|
>
|
|
<FileText size={14} className="text-muted-foreground" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">{t("notes.action.newNote")}</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={toolbarIconButtonClass}
|
|
onClick={startCreateGroup}
|
|
>
|
|
<FolderPlus size={14} className="text-muted-foreground" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">{t("notes.action.newGroup")}</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={toolbarIconButtonClass}
|
|
onClick={() => openImportMarkdownPicker()}
|
|
>
|
|
<Upload size={14} className="text-muted-foreground" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">{t("notes.action.importMarkdown")}</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={toolbarIconButtonClass}
|
|
disabled={sortedNotes.length === 0}
|
|
onClick={exportAllNotes}
|
|
>
|
|
<Download size={14} className="text-muted-foreground" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">{t("notes.action.exportAll")}</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={toolbarIconButtonClass}
|
|
disabled={!canExpandCollapse}
|
|
onClick={expandAllGroups}
|
|
>
|
|
<Expand size={14} className="text-muted-foreground" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">{t("vault.tree.expandAll")}</TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={toolbarIconButtonClass}
|
|
disabled={!canExpandCollapse}
|
|
onClick={collapseAllGroups}
|
|
>
|
|
<Minimize2 size={14} className="text-muted-foreground" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">{t("vault.tree.collapseAll")}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
<div
|
|
className={cn(
|
|
"overflow-hidden transition-[max-height,opacity] duration-200 ease-out",
|
|
expandedPanel === "search" ? "max-h-9 border-b border-border/60 opacity-100" : "max-h-0 opacity-0",
|
|
)}
|
|
>
|
|
<div className="flex h-9 items-center gap-0.5 px-1.5">
|
|
<div className="relative min-w-0 flex-1">
|
|
<Search
|
|
size={12}
|
|
className="pointer-events-none absolute left-1 top-1/2 -translate-y-1/2 text-muted-foreground"
|
|
/>
|
|
<Input
|
|
ref={searchInputRef}
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder={t("notes.search.placeholder")}
|
|
className="h-7 border-0 bg-transparent pl-6 pr-1 text-xs shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
/>
|
|
</div>
|
|
{hasSearch && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={toolbarIconButtonClass}
|
|
onClick={() => {
|
|
setQuery("");
|
|
searchInputRef.current?.focus();
|
|
}}
|
|
>
|
|
<X size={14} className="text-muted-foreground" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">{t("common.clear")}</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<ScrollArea className="flex-1">
|
|
<div
|
|
className="min-h-full space-y-1 px-1.5 pt-1.5 pb-4"
|
|
data-notes-drop-zone="root"
|
|
onDragOver={(event) => {
|
|
if (!hasNotesTreeDrag(event.dataTransfer)) return;
|
|
event.preventDefault();
|
|
event.dataTransfer.dropEffect = "move";
|
|
}}
|
|
onDrop={(event) => handleGroupDrop(null, event)}
|
|
>
|
|
{renderCreateGroupRow(null, 0)}
|
|
{visibleTree}
|
|
{visibleRootNotes.map((note) => renderNoteRow(note, 0))}
|
|
{treeIsEmpty && (
|
|
<div className="rounded-lg border border-dashed border-border/70 p-4 text-center text-sm text-muted-foreground">
|
|
<Search size={20} className="mx-auto mb-2 opacity-60" />
|
|
{query.trim() ? t("notes.search.noResults") : t("notes.empty.group")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
{!isSidebarMode && (
|
|
<div
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
aria-label={t("vault.sidebar.resize")}
|
|
className={cn(
|
|
"app-no-drag absolute right-0 top-0 z-20 h-full w-2 translate-x-1/2 cursor-col-resize",
|
|
"after:absolute after:right-1/2 after:top-2 after:h-[calc(100%-16px)] after:w-px after:translate-x-1/2 after:bg-border/0 after:transition-colors",
|
|
"hover:after:bg-border/70",
|
|
isTreeResizing && "after:bg-primary/70",
|
|
)}
|
|
onPointerDown={handleTreeResizeStart}
|
|
/>
|
|
)}
|
|
</aside>
|
|
)}
|
|
|
|
{!isSidebarMode && (
|
|
<main className="flex min-w-0 flex-1 flex-col bg-background">
|
|
{selectedNoteView ? (
|
|
<>
|
|
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-8 pt-6 pb-1" data-note-title-row>
|
|
<div className="min-w-0 flex-1">
|
|
<NoteTitleInput
|
|
noteId={selectedNoteView.id}
|
|
className="h-8 w-full bg-transparent text-lg font-semibold leading-8 outline-none placeholder:text-muted-foreground"
|
|
value={selectedNoteView.title}
|
|
placeholder={t("notes.title.placeholder")}
|
|
onCommit={(title) => saveNoteTitleDraft(selectedNoteView, title)}
|
|
onLiveDraft={(title) => stashNoteTitleDraft(selectedNoteView, title)}
|
|
onBlur={() => flushNoteDraft()}
|
|
/>
|
|
</div>
|
|
{renderNoteExportButton(selectedNoteView)}
|
|
{renderNoteModeToggle()}
|
|
</div>
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
<div
|
|
className="min-h-full w-full px-8 pt-2 pb-6"
|
|
onBlur={(event) => {
|
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
flushNoteDraft();
|
|
}
|
|
}}
|
|
>
|
|
<LazyLoadBoundary name="Notes editor" resetKey={selectedNoteView.id}>
|
|
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
|
<InlineMarkdownEditor
|
|
key={selectedNoteView.id}
|
|
value={selectedNoteView.content}
|
|
placeholder={t("notes.editor.placeholder")}
|
|
editorMode={noteEditorMode}
|
|
previewEmptyLabel={t("notes.preview.empty")}
|
|
onChange={(content) => saveNoteContentDraft(selectedNoteView.id, content)}
|
|
hosts={hosts}
|
|
onOpenHost={(host) => handleOpenHostFromNote(host, selectedNoteView.id)}
|
|
onOpenExternalLink={openExternal}
|
|
/>
|
|
</Suspense>
|
|
</LazyLoadBoundary>
|
|
</div>
|
|
</ScrollArea>
|
|
</>
|
|
) : (
|
|
<div className="flex flex-1 items-center justify-center px-4">
|
|
<div className="flex max-w-sm flex-col items-center text-center text-muted-foreground">
|
|
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-secondary/80">
|
|
<Edit2 size={30} className="opacity-60" />
|
|
</div>
|
|
<h3 className="mb-2 text-lg font-semibold text-foreground">{t("notes.empty.title")}</h3>
|
|
<p className="mb-4 text-sm">{t("notes.empty.desc")}</p>
|
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
|
<Button onClick={addNote}>
|
|
<Plus size={14} className="mr-2" />
|
|
{t("notes.action.newNote")}
|
|
</Button>
|
|
<Button variant="outline" onClick={() => openImportMarkdownPicker()}>
|
|
<Upload size={14} className="mr-2" />
|
|
{t("notes.action.importMarkdown")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
)}
|
|
</div>
|
|
|
|
{isSidebarMode && overlayNoteView && (
|
|
<div className="absolute inset-0 z-30 flex min-h-0 flex-col bg-background text-foreground">
|
|
<div className={cn(
|
|
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
|
|
"flex items-center gap-1 border-b border-border/60 px-1.5",
|
|
)}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={toolbarIconButtonClass}
|
|
onClick={() => setOverlayNoteId(null)}
|
|
>
|
|
<ArrowLeft size={14} className="text-muted-foreground" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">{t("common.back")}</TooltipContent>
|
|
</Tooltip>
|
|
<div className="min-w-0 flex-1 truncate px-1 text-xs font-medium leading-4 text-foreground">
|
|
{overlayNoteView.title || t("notes.title.placeholder")}
|
|
</div>
|
|
</div>
|
|
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
|
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-4 pt-5 pb-1" data-note-title-row>
|
|
<div className="min-w-0 flex-1">
|
|
<NoteTitleInput
|
|
noteId={overlayNoteView.id}
|
|
className="h-8 w-full bg-transparent text-lg font-semibold leading-8 outline-none placeholder:text-muted-foreground"
|
|
value={overlayNoteView.title}
|
|
placeholder={t("notes.title.placeholder")}
|
|
onCommit={(title) => saveNoteTitleDraft(overlayNoteView, title)}
|
|
onLiveDraft={(title) => stashNoteTitleDraft(overlayNoteView, title)}
|
|
onBlur={() => flushNoteDraft()}
|
|
/>
|
|
</div>
|
|
{renderNoteExportButton(overlayNoteView)}
|
|
{renderNoteModeToggle()}
|
|
</div>
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
<div
|
|
className="min-h-full w-full px-4 pt-2 pb-6"
|
|
onBlur={(event) => {
|
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
flushNoteDraft();
|
|
}
|
|
}}
|
|
>
|
|
<LazyLoadBoundary name="Notes editor" resetKey={overlayNoteView.id}>
|
|
<Suspense fallback={<InlineMarkdownEditorFallback />}>
|
|
<InlineMarkdownEditor
|
|
key={overlayNoteView.id}
|
|
value={overlayNoteView.content}
|
|
placeholder={t("notes.editor.placeholder")}
|
|
editorMode={noteEditorMode}
|
|
onChange={(content) => saveNoteContentDraft(overlayNoteView.id, content)}
|
|
previewEmptyLabel={t("notes.preview.empty")}
|
|
hosts={hosts}
|
|
onOpenHost={(host) => handleOpenHostFromNote(host, overlayNoteView.id)}
|
|
onOpenExternalLink={openExternal}
|
|
/>
|
|
</Suspense>
|
|
</LazyLoadBoundary>
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<VaultDeleteConfirmDialog
|
|
open={Boolean(deleteTarget)}
|
|
title={t("vault.deleteConfirm.title", {
|
|
name: deleteTarget?.name ?? "",
|
|
})}
|
|
description={
|
|
deleteTarget?.type === "group"
|
|
? t("vault.deleteConfirm.noteGroupDesc")
|
|
: t("vault.deleteConfirm.desc")
|
|
}
|
|
onOpenChange={(open) => {
|
|
if (!open) setDeleteTarget(null);
|
|
}}
|
|
onConfirm={confirmDeleteTarget}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|