From 75dd6ff5276b89182dbe4154dd5beed3be57945e Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Fri, 3 Apr 2026 15:39:33 +0900 Subject: [PATCH 1/2] feat(desktop): enhance browser bookmarks --- .../src/lib/trpc/routers/external/index.ts | 81 ++- .../TabView/BrowserPane/BrowserPane.tsx | 13 +- .../components/BookmarkBar/BookmarkBar.tsx | 78 ++- .../BookmarkBarItem/BookmarkBarItem.tsx | 259 +++++-- .../EditBookmarkDialog/EditBookmarkDialog.tsx | 39 +- .../BookmarkFolderItem/BookmarkFolderItem.tsx | 333 +++++++++ .../components/BookmarkFolderItem/index.ts | 1 + .../BookmarkFolderDialog.tsx | 181 +++++ .../components/BookmarkFolderDialog/index.ts | 1 + .../BrowserOverflowMenu.tsx | 219 ++++-- .../hooks/usePersistentWebview/index.ts | 1 + .../usePersistentWebview.ts | 24 +- .../ChangesHeader/ChangesHeader.tsx | 7 +- .../stores/browser-bookmark-folder-icons.tsx | 57 ++ .../renderer/stores/browser-bookmarks-html.ts | 125 ++++ .../src/renderer/stores/browser-bookmarks.ts | 641 ++++++++++++++++-- 16 files changed, 1856 insertions(+), 204 deletions(-) create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkFolderItem/BookmarkFolderItem.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkFolderItem/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkFolderDialog/BookmarkFolderDialog.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkFolderDialog/index.ts create mode 100644 apps/desktop/src/renderer/stores/browser-bookmark-folder-icons.tsx create mode 100644 apps/desktop/src/renderer/stores/browser-bookmarks-html.ts diff --git a/apps/desktop/src/lib/trpc/routers/external/index.ts b/apps/desktop/src/lib/trpc/routers/external/index.ts index d8ced5287ea..036a754f1d7 100644 --- a/apps/desktop/src/lib/trpc/routers/external/index.ts +++ b/apps/desktop/src/lib/trpc/routers/external/index.ts @@ -1,3 +1,4 @@ +import { readFile, writeFile } from "node:fs/promises"; import { EXTERNAL_APPS, NON_EDITOR_APPS, @@ -6,7 +7,13 @@ import { } from "@superset/local-db"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; -import { clipboard, shell } from "electron"; +import { + BrowserWindow, + clipboard, + dialog, + type OpenDialogOptions, + shell, +} from "electron"; import { localDb } from "main/lib/local-db"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -18,6 +25,10 @@ import { } from "./helpers"; const ExternalAppSchema = z.enum(EXTERNAL_APPS); +const FileFilterSchema = z.object({ + name: z.string(), + extensions: z.array(z.string()), +}); const nonEditorSet = new Set(NON_EDITOR_APPS); @@ -191,6 +202,74 @@ export const createExternalRouter = () => { clipboard.writeText(input); }), + openTextFile: publicProcedure + .input( + z.object({ + title: z.string().optional(), + buttonLabel: z.string().optional(), + filters: z.array(FileFilterSchema).optional(), + }), + ) + .mutation(async ({ input }) => { + const window = BrowserWindow.getFocusedWindow(); + const options: OpenDialogOptions = { + title: input.title, + buttonLabel: input.buttonLabel, + filters: input.filters, + properties: ["openFile"], + }; + const result = window + ? await dialog.showOpenDialog(window, options) + : await dialog.showOpenDialog(options); + + if (result.canceled || result.filePaths.length === 0) { + return null; + } + + const filePath = result.filePaths[0]; + if (!filePath) { + return null; + } + + const content = await readFile(filePath, "utf-8"); + return { + path: filePath, + content, + }; + }), + + saveTextFile: publicProcedure + .input( + z.object({ + title: z.string().optional(), + defaultPath: z.string().optional(), + buttonLabel: z.string().optional(), + filters: z.array(FileFilterSchema).optional(), + content: z.string(), + }), + ) + .mutation(async ({ input }) => { + const window = BrowserWindow.getFocusedWindow(); + const options = { + title: input.title, + defaultPath: input.defaultPath, + buttonLabel: input.buttonLabel, + filters: input.filters, + }; + const result = window + ? await dialog.showSaveDialog(window, options) + : await dialog.showSaveDialog(options); + + if (result.canceled || !result.filePath) { + return null; + } + + await writeFile(result.filePath, input.content, "utf-8"); + return { + path: result.filePath, + }; + }), + resolvePath: publicProcedure .input( z.object({ diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx index e3b2bc8c32e..a64a871292b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx @@ -1,12 +1,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { GlobeIcon } from "lucide-react"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { LuMinus, LuPlus } from "react-icons/lu"; import { TbDeviceDesktop } from "react-icons/tb"; import type { MosaicBranch } from "react-mosaic-component"; import { electronTrpc } from "renderer/lib/electron-trpc"; import { - normalizeBookmarkUrl, + findBookmarkByUrl, useBrowserBookmarksStore, } from "renderer/stores/browser-bookmarks"; import { useTabsStore } from "renderer/stores/tabs/store"; @@ -55,11 +55,10 @@ export function BrowserPane({ const isLoading = browserState?.isLoading ?? false; const loadError = browserState?.error ?? null; const isBlankPage = currentUrl === "about:blank"; - const currentBookmark = useBrowserBookmarksStore((state) => - state.bookmarks.find( - (bookmark) => - normalizeBookmarkUrl(bookmark.url) === normalizeBookmarkUrl(currentUrl), - ), + const bookmarks = useBrowserBookmarksStore((state) => state.bookmarks); + const currentBookmark = useMemo( + () => findBookmarkByUrl(bookmarks, currentUrl), + [bookmarks, currentUrl], ); const toggleBookmark = useBrowserBookmarksStore( (state) => state.toggleBookmark, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/BookmarkBar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/BookmarkBar.tsx index b6b6925b96e..75366828882 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/BookmarkBar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/BookmarkBar.tsx @@ -1,4 +1,5 @@ import { + closestCenter, DndContext, type DragEndEvent, MouseSensor, @@ -10,12 +11,16 @@ import { SortableContext, } from "@dnd-kit/sortable"; import { cn } from "@superset/ui/utils"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { + folderContainsBookmarkUrl, + isBrowserBookmark, normalizeBookmarkUrl, useBrowserBookmarksStore, } from "renderer/stores/browser-bookmarks"; +import { setPersistentWebviewInteractionLock } from "../../hooks/usePersistentWebview"; import { BookmarkBarItem } from "./components/BookmarkBarItem"; +import { BookmarkFolderItem } from "./components/BookmarkFolderItem"; interface BookmarkBarProps { currentUrl: string; @@ -24,11 +29,7 @@ interface BookmarkBarProps { export function BookmarkBar({ currentUrl, onNavigate }: BookmarkBarProps) { const bookmarks = useBrowserBookmarksStore((state) => state.bookmarks); - const moveBookmark = useBrowserBookmarksStore((state) => state.moveBookmark); - const removeBookmark = useBrowserBookmarksStore( - (state) => state.removeBookmark, - ); - + const moveNode = useBrowserBookmarksStore((state) => state.moveNode); const normalizedCurrentUrl = useMemo( () => normalizeBookmarkUrl(currentUrl), [currentUrl], @@ -40,31 +41,66 @@ export function BookmarkBar({ currentUrl, onNavigate }: BookmarkBarProps) { }), ); + useEffect(() => { + return () => { + setPersistentWebviewInteractionLock("bookmark-bar-dnd", false); + }; + }, []); + const handleDragEnd = ({ active, over }: DragEndEvent) => { + setPersistentWebviewInteractionLock("bookmark-bar-dnd", false); if (!over) return; - moveBookmark(String(active.id), String(over.id)); + moveNode(String(active.id), String(over.id)); }; + const rootIds = useMemo( + () => bookmarks.map((bookmark) => bookmark.id), + [bookmarks], + ); + return (
{bookmarks.length > 0 ? ( - + { + setPersistentWebviewInteractionLock("bookmark-bar-dnd", true); + }} + onDragCancel={() => { + setPersistentWebviewInteractionLock("bookmark-bar-dnd", false); + }} + onDragEnd={handleDragEnd} + > bookmark.id)} + items={rootIds} strategy={horizontalListSortingStrategy} > -
- {bookmarks.map((bookmark) => ( - - ))} +
+ {bookmarks.map((bookmark) => + isBrowserBookmark(bookmark) ? ( + + ) : ( + + ), + )}
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkBarItem/BookmarkBarItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkBarItem/BookmarkBarItem.tsx index 24cdca7d232..ad74158765d 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkBarItem/BookmarkBarItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkBarItem/BookmarkBarItem.tsx @@ -10,9 +10,17 @@ import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { GlobeIcon } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { + type ComponentPropsWithoutRef, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { type BrowserBookmark, + findBookmarkParentFolderId, + getBookmarkFolderOptions, useBrowserBookmarksStore, } from "renderer/stores/browser-bookmarks"; import { EditBookmarkDialog } from "./components/EditBookmarkDialog"; @@ -21,24 +29,93 @@ interface BookmarkBarItemProps { bookmark: BrowserBookmark; isActive: boolean; onNavigate: (url: string) => void; - onRemove: (bookmarkId: string) => void; + sortable?: boolean; + compact?: boolean; + dragAxis?: "horizontal" | "vertical"; } -export function BookmarkBarItem({ +interface BookmarkButtonProps { + bookmark: BrowserBookmark; + isActive: boolean; + label: string; + faviconFailed: boolean; + onNavigate: (url: string) => void; + onFaviconError: () => void; + compact: boolean; + sortable: boolean; + attributes?: ComponentPropsWithoutRef<"button">; + listeners?: ComponentPropsWithoutRef<"button">; +} + +function BookmarkButton({ bookmark, isActive, + label, + faviconFailed, onNavigate, - onRemove, -}: BookmarkBarItemProps) { - const [faviconFailed, setFaviconFailed] = useState(false); - const [isEditOpen, setIsEditOpen] = useState(false); - const shouldOpenEditDialogRef = useRef(false); - const pendingOpenTimerRef = useRef | null>( - null, - ); - const updateBookmark = useBrowserBookmarksStore( - (state) => state.updateBookmark, + onFaviconError, + compact, + sortable, + attributes, + listeners, +}: BookmarkButtonProps) { + return ( + + + + + + {bookmark.url} + + ); +} + +interface SortableBookmarkTriggerProps { + bookmark: BrowserBookmark; + isActive: boolean; + label: string; + faviconFailed: boolean; + onNavigate: (url: string) => void; + onFaviconError: () => void; + compact: boolean; + dragAxis: "horizontal" | "vertical"; +} + +function SortableBookmarkTrigger({ + bookmark, + isActive, + label, + faviconFailed, + onNavigate, + onFaviconError, + compact, + dragAxis, +}: SortableBookmarkTriggerProps) { const { attributes, listeners, @@ -52,12 +129,76 @@ export function BookmarkBarItem({ const style = useMemo( () => ({ - transform: CSS.Transform.toString(transform), + transform: CSS.Transform.toString( + transform + ? dragAxis === "vertical" + ? { ...transform, x: 0 } + : { ...transform, y: 0 } + : null, + ), transition, }), - [transform, transition], + [dragAxis, transform, transition], ); + return ( + +
+ +
+
+ ); +} + +export function BookmarkBarItem({ + bookmark, + isActive, + onNavigate, + sortable = true, + compact = false, + dragAxis = "horizontal", +}: BookmarkBarItemProps) { + const [faviconFailed, setFaviconFailed] = useState(false); + const [isEditOpen, setIsEditOpen] = useState(false); + const shouldOpenEditDialogRef = useRef(false); + const pendingOpenTimerRef = useRef | null>( + null, + ); + const updateBookmark = useBrowserBookmarksStore( + (state) => state.updateBookmark, + ); + const duplicateBookmark = useBrowserBookmarksStore( + (state) => state.duplicateBookmark, + ); + const removeNode = useBrowserBookmarksStore((state) => state.removeNode); + const bookmarks = useBrowserBookmarksStore((state) => state.bookmarks); + const folderOptions = useMemo( + () => getBookmarkFolderOptions(bookmarks), + [bookmarks], + ); + const currentFolderId = useMemo( + () => findBookmarkParentFolderId(bookmarks, bookmark.id), + [bookmarks, bookmark.id], + ); const label = bookmark.title.trim() || bookmark.url; useEffect(() => { @@ -81,48 +222,35 @@ export function BookmarkBarItem({ return ( <> - -
- - - - - - {bookmark.url} - - -
-
+ {sortable ? ( + setFaviconFailed(true)} + compact={compact} + dragAxis={dragAxis} + /> + ) : ( + +
+ setFaviconFailed(true)} + compact={compact} + sortable={false} + /> +
+
+ )} { if (!shouldOpenEditDialogRef.current) return; shouldOpenEditDialogRef.current = false; @@ -133,6 +261,18 @@ export function BookmarkBarItem({ onNavigate(bookmark.url)}> Open Bookmark + { + const duplicatedBookmark = duplicateBookmark(bookmark.id); + if (!duplicatedBookmark) { + toast.error("Failed to duplicate bookmark"); + return; + } + toast.success("Bookmark duplicated"); + }} + > + Copy Bookmark + { shouldOpenEditDialogRef.current = true; @@ -140,7 +280,7 @@ export function BookmarkBarItem({ > Edit Bookmark - onRemove(bookmark.id)}> + removeNode(bookmark.id)}> Remove Bookmark @@ -149,10 +289,13 @@ export function BookmarkBarItem({ bookmark={bookmark} open={isEditOpen} onOpenChange={setIsEditOpen} - onSave={({ title, url }) => { + folderOptions={folderOptions} + initialFolderId={currentFolderId} + onSave={({ title, url, folderId }) => { const updatedBookmark = updateBookmark(bookmark.id, { title, url, + folderId, faviconUrl: bookmark.faviconUrl, }); if (!updatedBookmark) { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkBarItem/components/EditBookmarkDialog/EditBookmarkDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkBarItem/components/EditBookmarkDialog/EditBookmarkDialog.tsx index ff1a61cd54b..b9d2b55db9b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkBarItem/components/EditBookmarkDialog/EditBookmarkDialog.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkBarItem/components/EditBookmarkDialog/EditBookmarkDialog.tsx @@ -11,27 +11,42 @@ import { Label } from "@superset/ui/label"; import { useEffect, useState } from "react"; import type { BrowserBookmark } from "renderer/stores/browser-bookmarks"; +interface FolderOption { + id: string; + label: string; +} + interface EditBookmarkDialogProps { bookmark: BrowserBookmark; open: boolean; onOpenChange: (open: boolean) => void; - onSave: (values: { title: string; url: string }) => void; + folderOptions: FolderOption[]; + initialFolderId: string | null; + onSave: (values: { + title: string; + url: string; + folderId: string | null; + }) => void; } export function EditBookmarkDialog({ bookmark, open, onOpenChange, + folderOptions, + initialFolderId, onSave, }: EditBookmarkDialogProps) { const [title, setTitle] = useState(bookmark.title); const [url, setUrl] = useState(bookmark.url); + const [folderId, setFolderId] = useState(initialFolderId); useEffect(() => { if (!open) return; setTitle(bookmark.title); setUrl(bookmark.url); - }, [bookmark.title, bookmark.url, open]); + setFolderId(initialFolderId); + }, [bookmark.title, bookmark.url, initialFolderId, open]); return ( @@ -43,7 +58,7 @@ export function EditBookmarkDialog({ className="space-y-4" onSubmit={(event) => { event.preventDefault(); - onSave({ title, url }); + onSave({ title, url, folderId }); }} >
@@ -68,6 +83,24 @@ export function EditBookmarkDialog({ autoCorrect="off" />
+
+ + +
+ + + + {folder.title} + + + +
+ + {folder.children.length > 0 ? ( + + ) : ( +
+ Folder is empty. +
+ )} +
+ +
+ + { + if (!shouldOpenEditDialogRef.current) return; + shouldOpenEditDialogRef.current = false; + event.preventDefault(); + scheduleEditDialogOpen(); + }} + > + { + shouldOpenEditDialogRef.current = true; + }} + > + Edit Folder + + removeNode(folder.id)}> + Remove Folder + + + + { + const updatedFolder = updateFolder(folder.id, { + title, + iconKey, + color, + }); + if (!updatedFolder) { + toast.error("Failed to update folder"); + return; + } + setIsEditOpen(false); + toast.success("Folder updated"); + }} + /> + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkFolderItem/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkFolderItem/index.ts new file mode 100644 index 00000000000..c3b80a3575b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkFolderItem/index.ts @@ -0,0 +1 @@ +export { BookmarkFolderItem } from "./BookmarkFolderItem"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkFolderDialog/BookmarkFolderDialog.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkFolderDialog/BookmarkFolderDialog.tsx new file mode 100644 index 00000000000..b9275629aa3 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkFolderDialog/BookmarkFolderDialog.tsx @@ -0,0 +1,181 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { useEffect, useState } from "react"; +import { + BROWSER_BOOKMARK_FOLDER_ICON_OPTIONS, + type BrowserBookmarkFolderIconKey, +} from "renderer/stores/browser-bookmark-folder-icons"; + +const FOLDER_COLOR_PRESETS = [ + { label: "Slate", value: "#64748b" }, + { label: "Blue", value: "#2563eb" }, + { label: "Cyan", value: "#0891b2" }, + { label: "Green", value: "#16a34a" }, + { label: "Amber", value: "#d97706" }, + { label: "Rose", value: "#e11d48" }, + { label: "Violet", value: "#7c3aed" }, + { label: "Gray", value: "#6b7280" }, +] as const; + +interface BookmarkFolderDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + initialTitle?: string; + initialIconKey?: BrowserBookmarkFolderIconKey; + initialColor?: string | null; + dialogTitle: string; + submitLabel: string; + onSave: (values: { + title: string; + iconKey: BrowserBookmarkFolderIconKey; + color: string | null; + }) => void; +} + +export function BookmarkFolderDialog({ + open, + onOpenChange, + initialTitle = "", + initialIconKey = "folder", + initialColor = null, + dialogTitle, + submitLabel, + onSave, +}: BookmarkFolderDialogProps) { + const [title, setTitle] = useState(initialTitle); + const [iconKey, setIconKey] = + useState(initialIconKey); + const [color, setColor] = useState(initialColor ?? "#64748b"); + + useEffect(() => { + if (!open) return; + setTitle(initialTitle); + setIconKey(initialIconKey); + setColor(initialColor ?? "#64748b"); + }, [initialColor, initialIconKey, initialTitle, open]); + + return ( + + + + {dialogTitle} + +
{ + event.preventDefault(); + onSave({ title, iconKey, color }); + }} + > +
+ + setTitle(event.target.value)} + placeholder="Folder name" + autoFocus + /> +
+
+ +
+ {BROWSER_BOOKMARK_FOLDER_ICON_OPTIONS.map((option) => { + const Icon = option.icon; + const isSelected = option.key === iconKey; + + return ( + + ); + })} +
+
+
+ +
+ {FOLDER_COLOR_PRESETS.map((preset) => { + const isSelected = preset.value === color; + + return ( + + ); + })} +
+
+
+ + setColor(event.target.value)} + className="h-9 w-14 cursor-pointer rounded border border-input bg-background p-1" + /> +
+ +
+
+ + + + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkFolderDialog/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkFolderDialog/index.ts new file mode 100644 index 00000000000..f92829cd85b --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkFolderDialog/index.ts @@ -0,0 +1 @@ +export { BookmarkFolderDialog } from "./BookmarkFolderDialog"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserToolbar/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserToolbar/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx index 318ee5aa9f4..377075e1713 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserToolbar/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserToolbar/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx @@ -5,21 +5,31 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; +import { toast } from "@superset/ui/sonner"; +import { useEffect, useRef, useState } from "react"; import { TbCamera, TbClock, TbCopy, TbDots, + TbDownload, + TbFolderPlus, TbReload, TbTrash, + TbUpload, } from "react-icons/tb"; import { useCopyToClipboard } from "renderer/hooks/useCopyToClipboard"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useBrowserBookmarksStore } from "renderer/stores/browser-bookmarks"; +import { + exportBrowserBookmarksToHtml, + importBrowserBookmarksFromHtml, +} from "renderer/stores/browser-bookmarks-html"; import { useTabsStore } from "renderer/stores/tabs/store"; +import { BookmarkFolderDialog } from "../../../BookmarkFolderDialog"; interface BrowserOverflowMenuProps { paneId: string; - /** Whether a real page is loaded (not about:blank) */ hasPage: boolean; } @@ -32,7 +42,37 @@ export function BrowserOverflowMenu({ const clearBrowsingDataMutation = electronTrpc.browser.clearBrowsingData.useMutation(); const clearHistoryMutation = electronTrpc.browserHistory.clear.useMutation(); + const openTextFileMutation = electronTrpc.external.openTextFile.useMutation(); + const saveTextFileMutation = electronTrpc.external.saveTextFile.useMutation(); const currentUrl = useTabsStore((s) => s.panes[paneId]?.browser?.currentUrl); + const bookmarks = useBrowserBookmarksStore((state) => state.bookmarks); + const addFolder = useBrowserBookmarksStore((state) => state.addFolder); + const importBookmarks = useBrowserBookmarksStore( + (state) => state.importBookmarks, + ); + const [isNewFolderOpen, setIsNewFolderOpen] = useState(false); + const shouldOpenNewFolderDialogRef = useRef(false); + const pendingOpenTimerRef = useRef | null>( + null, + ); + + useEffect(() => { + return () => { + if (pendingOpenTimerRef.current !== null) { + clearTimeout(pendingOpenTimerRef.current); + } + }; + }, []); + + const scheduleNewFolderDialogOpen = () => { + if (pendingOpenTimerRef.current !== null) { + clearTimeout(pendingOpenTimerRef.current); + } + pendingOpenTimerRef.current = setTimeout(() => { + pendingOpenTimerRef.current = null; + setIsNewFolderOpen(true); + }, 0); + }; const handleScreenshot = () => { screenshotMutation.mutate({ paneId }); @@ -50,6 +90,47 @@ export function BrowserOverflowMenu({ } }; + const handleImportBookmarks = async () => { + const file = await openTextFileMutation.mutateAsync({ + title: "Import Bookmarks", + buttonLabel: "Import", + filters: [{ name: "Bookmarks HTML", extensions: ["html", "htm"] }], + }); + + if (!file) { + return; + } + + const importedNodes = importBrowserBookmarksFromHtml(file.content); + const result = importBookmarks(importedNodes); + + if (result.bookmarksAdded === 0 && result.foldersAdded === 0) { + toast.error("No bookmarks were imported"); + return; + } + + toast.success( + `Imported ${result.bookmarksAdded} bookmarks and ${result.foldersAdded} folders`, + ); + }; + + const handleExportBookmarks = async () => { + const content = exportBrowserBookmarksToHtml(bookmarks); + const saved = await saveTextFileMutation.mutateAsync({ + title: "Export Bookmarks", + defaultPath: "bookmarks.html", + buttonLabel: "Export", + filters: [{ name: "Bookmarks HTML", extensions: ["html"] }], + content, + }); + + if (!saved) { + return; + } + + toast.success("Bookmarks exported"); + }; + const handleClearCookies = () => { clearBrowsingDataMutation.mutate({ type: "cookies" }); }; @@ -63,54 +144,94 @@ export function BrowserOverflowMenu({ }; return ( - - - - - - - - Take Screenshot - - - - Hard Reload - - + + + + + { + if (!shouldOpenNewFolderDialogRef.current) return; + shouldOpenNewFolderDialogRef.current = false; + event.preventDefault(); + scheduleNewFolderDialogOpen(); + }} > - - Copy URL - - - - - Clear Browsing History - - - - Clear Cookies - - - - Clear All Data - - - + + + Take Screenshot + + + + Hard Reload + + + + Copy URL + + + { + shouldOpenNewFolderDialogRef.current = true; + }} + className="gap-2" + > + + New Folder + + + + Import Bookmarks + + + + Export Bookmarks + + + + + Clear Browsing History + + + + Clear Cookies + + + + Clear All Data + + + + { + addFolder({ title, iconKey, color }); + setIsNewFolderOpen(false); + toast.success("Folder created"); + }} + /> + ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/index.ts index 65caa2bb36a..d6892389536 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/index.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/index.ts @@ -1,4 +1,5 @@ export { destroyPersistentWebview, + setPersistentWebviewInteractionLock, usePersistentWebview, } from "./usePersistentWebview"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts index 4bf259aba88..7a898676f16 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/hooks/usePersistentWebview/usePersistentWebview.ts @@ -41,6 +41,7 @@ import { * hidden parking container, but the webview inside is untouched. */ let hiddenContainer: HTMLDivElement | null = null; +const webviewInteractionLocks = new Set(); function getHiddenContainer(): HTMLDivElement { if (!hiddenContainer) { @@ -75,17 +76,34 @@ function setWebviewsDragPassthrough(passthrough: boolean) { }); } +export function setPersistentWebviewInteractionLock( + lockId: string, + enabled: boolean, +) { + if (enabled) { + webviewInteractionLocks.add(lockId); + } else { + webviewInteractionLocks.delete(lockId); + } + + setWebviewsDragPassthrough(webviewInteractionLocks.size > 0); +} + window.addEventListener( "dragstart", - () => setWebviewsDragPassthrough(true), + () => setPersistentWebviewInteractionLock("native-drag", true), true, ); window.addEventListener( "dragend", - () => setWebviewsDragPassthrough(false), + () => setPersistentWebviewInteractionLock("native-drag", false), + true, +); +window.addEventListener( + "drop", + () => setPersistentWebviewInteractionLock("native-drag", false), true, ); -window.addEventListener("drop", () => setWebviewsDragPassthrough(false), true); /** Call from useBrowserLifecycle when a pane is removed. */ export function destroyPersistentWebview(paneId: string): void { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx index 41d4e08e281..cb52e837516 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/ChangesHeader/ChangesHeader.tsx @@ -1136,12 +1136,15 @@ function StashDropdown({ function FetchRemoteButton({ worktreePath, onRefresh, -}: { worktreePath: string; onRefresh: () => void }) { +}: { + worktreePath: string; + onRefresh: () => void; +}) { const utils = electronTrpc.useUtils(); const [isDisabled, setIsDisabled] = useState(false); const timeoutRef = useRef(null); - const fetchMutation = electronTrpc.gitOperations.fetch.useMutation({ + const fetchMutation = electronTrpc.changes.fetch.useMutation({ onSuccess: () => { void utils.changes.getBranches.invalidate({ worktreePath }); void utils.changes.getStatus.invalidate(); diff --git a/apps/desktop/src/renderer/stores/browser-bookmark-folder-icons.tsx b/apps/desktop/src/renderer/stores/browser-bookmark-folder-icons.tsx new file mode 100644 index 00000000000..57ff0129364 --- /dev/null +++ b/apps/desktop/src/renderer/stores/browser-bookmark-folder-icons.tsx @@ -0,0 +1,57 @@ +import type { LucideIcon } from "lucide-react"; +import { + BookIcon, + BriefcaseIcon, + CodeIcon, + FileIcon, + FolderIcon, + GlobeIcon, + HeartIcon, + ImageIcon, + StarIcon, +} from "lucide-react"; + +const FOLDER_ICONS = { + folder: FolderIcon, + star: StarIcon, + globe: GlobeIcon, + code: CodeIcon, + briefcase: BriefcaseIcon, + image: ImageIcon, + heart: HeartIcon, + book: BookIcon, + file: FileIcon, +} as const; + +export type BrowserBookmarkFolderIconKey = keyof typeof FOLDER_ICONS; + +export interface BrowserBookmarkFolderIconOption { + key: BrowserBookmarkFolderIconKey; + label: string; + icon: LucideIcon; +} + +export const BROWSER_BOOKMARK_FOLDER_ICON_OPTIONS: BrowserBookmarkFolderIconOption[] = + [ + { key: "folder", label: "Folder", icon: FolderIcon }, + { key: "star", label: "Star", icon: StarIcon }, + { key: "globe", label: "Globe", icon: GlobeIcon }, + { key: "code", label: "Code", icon: CodeIcon }, + { key: "briefcase", label: "Briefcase", icon: BriefcaseIcon }, + { key: "image", label: "Image", icon: ImageIcon }, + { key: "heart", label: "Heart", icon: HeartIcon }, + { key: "book", label: "Book", icon: BookIcon }, + { key: "file", label: "File", icon: FileIcon }, + ]; + +export function isBrowserBookmarkFolderIconKey( + value: unknown, +): value is BrowserBookmarkFolderIconKey { + return typeof value === "string" && value in FOLDER_ICONS; +} + +export function getBrowserBookmarkFolderIcon( + iconKey?: BrowserBookmarkFolderIconKey, +): LucideIcon { + return FOLDER_ICONS[iconKey ?? "folder"] ?? FolderIcon; +} diff --git a/apps/desktop/src/renderer/stores/browser-bookmarks-html.ts b/apps/desktop/src/renderer/stores/browser-bookmarks-html.ts new file mode 100644 index 00000000000..75497164147 --- /dev/null +++ b/apps/desktop/src/renderer/stores/browser-bookmarks-html.ts @@ -0,0 +1,125 @@ +import type { + BrowserBookmark, + BrowserBookmarkTreeNode, +} from "./browser-bookmarks"; +import { isBrowserBookmark, normalizeBookmarkUrl } from "./browser-bookmarks"; + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function buildBookmarkHtml(node: BrowserBookmarkTreeNode, depth = 1): string { + const indent = " ".repeat(depth); + + if (isBrowserBookmark(node)) { + const parts = [ + `${indent}
${escapeHtml(node.title || node.url)}\n`); + return parts.join(""); + } + + const title = node.title.trim() || "Untitled Folder"; + return [ + `${indent}

${escapeHtml(title)}

\n`, + `${indent}

\n`, + ...node.children.map((child) => buildBookmarkHtml(child, depth + 1)), + `${indent}

\n`, + ].join(""); +} + +function parseTimestamp(value: string | null): number { + if (!value) return Date.now(); + const unixSeconds = Number(value); + return Number.isFinite(unixSeconds) ? unixSeconds * 1000 : Date.now(); +} + +function parseBookmarkAnchor( + anchor: HTMLAnchorElement, +): BrowserBookmark | null { + const href = normalizeBookmarkUrl(anchor.getAttribute("href") ?? ""); + if (!href || href === "about:blank") { + return null; + } + + return { + id: crypto.randomUUID(), + type: "bookmark", + url: href, + title: anchor.textContent?.trim() || href, + faviconUrl: anchor.getAttribute("icon") ?? undefined, + createdAt: parseTimestamp(anchor.getAttribute("add_date")), + }; +} + +function parseBookmarkList(list: Element | null): BrowserBookmarkTreeNode[] { + if (!list) return []; + + const nodes: BrowserBookmarkTreeNode[] = []; + for (const child of Array.from(list.children)) { + if (child.tagName !== "DT") continue; + + const heading = Array.from(child.children).find((element) => + /^H[1-6]$/i.test(element.tagName), + ); + if (heading) { + const nestedList = + child.nextElementSibling?.tagName === "DL" + ? child.nextElementSibling + : null; + nodes.push({ + id: crypto.randomUUID(), + type: "folder", + title: heading.textContent?.trim() || "Untitled Folder", + createdAt: parseTimestamp(heading.getAttribute("add_date")), + children: parseBookmarkList(nestedList), + }); + continue; + } + + const anchor = child.querySelector("a"); + if (!anchor) continue; + const bookmark = parseBookmarkAnchor(anchor); + if (bookmark) { + nodes.push(bookmark); + } + } + + return nodes; +} + +export function exportBrowserBookmarksToHtml( + nodes: BrowserBookmarkTreeNode[], +): string { + return [ + "", + '', + "Bookmarks", + "

Bookmarks

", + "

", + ...nodes.map((node) => buildBookmarkHtml(node)), + "

", + "", + ].join("\n"); +} + +export function importBrowserBookmarksFromHtml( + html: string, +): BrowserBookmarkTreeNode[] { + const parser = new DOMParser(); + const document = parser.parseFromString(html, "text/html"); + const rootList = + document.querySelector("body > dl") ?? document.querySelector("dl"); + + return parseBookmarkList(rootList); +} diff --git a/apps/desktop/src/renderer/stores/browser-bookmarks.ts b/apps/desktop/src/renderer/stores/browser-bookmarks.ts index 974ab301c75..acce3866734 100644 --- a/apps/desktop/src/renderer/stores/browser-bookmarks.ts +++ b/apps/desktop/src/renderer/stores/browser-bookmarks.ts @@ -1,30 +1,91 @@ import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; +import { + type BrowserBookmarkFolderIconKey, + isBrowserBookmarkFolderIconKey, +} from "./browser-bookmark-folder-icons"; export interface BrowserBookmark { id: string; + type: "bookmark"; url: string; title: string; faviconUrl?: string; createdAt: number; } -interface BrowserBookmarkInput { +export interface BrowserBookmarkFolder { + id: string; + type: "folder"; + title: string; + iconKey?: BrowserBookmarkFolderIconKey; + color?: string | null; + children: BrowserBookmarkTreeNode[]; + createdAt: number; +} + +export type BrowserBookmarkTreeNode = BrowserBookmark | BrowserBookmarkFolder; + +export interface BrowserBookmarkInput { url: string; title: string; faviconUrl?: string; + folderId?: string | null; +} + +export interface BrowserBookmarkFolderInput { + title: string; + iconKey?: BrowserBookmarkFolderIconKey; + color?: string | null; +} + +interface FolderOption { + id: string; + label: string; } interface BrowserBookmarksState { - bookmarks: BrowserBookmark[]; + bookmarks: BrowserBookmarkTreeNode[]; addBookmark: (bookmark: BrowserBookmarkInput) => BrowserBookmark | null; + duplicateBookmark: (bookmarkId: string) => BrowserBookmark | null; updateBookmark: ( bookmarkId: string, bookmark: BrowserBookmarkInput, ) => BrowserBookmark | null; - removeBookmark: (bookmarkId: string) => void; - moveBookmark: (activeId: string, overId: string) => void; + addFolder: (folder: BrowserBookmarkFolderInput) => BrowserBookmarkFolder; + updateFolder: ( + folderId: string, + folder: BrowserBookmarkFolderInput, + ) => BrowserBookmarkFolder | null; + reorderFolderChildren: ( + folderId: string, + activeId: string, + overId: string, + ) => void; + removeNode: (nodeId: string) => void; + moveNode: (activeId: string, overId: string) => void; toggleBookmark: (bookmark: BrowserBookmarkInput) => boolean; + importBookmarks: (nodes: BrowserBookmarkTreeNode[]) => { + bookmarksAdded: number; + foldersAdded: number; + skipped: number; + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function isBrowserBookmark( + node: BrowserBookmarkTreeNode, +): node is BrowserBookmark { + return node.type === "bookmark"; +} + +export function isBrowserBookmarkFolder( + node: BrowserBookmarkTreeNode, +): node is BrowserBookmarkFolder { + return node.type === "folder"; } export function normalizeBookmarkUrl(url: string): string { @@ -42,13 +103,6 @@ export function normalizeBookmarkUrl(url: string): string { } } -function findBookmarkIndex(bookmarks: BrowserBookmark[], url: string): number { - const normalizedUrl = normalizeBookmarkUrl(url); - return bookmarks.findIndex( - (bookmark) => normalizeBookmarkUrl(bookmark.url) === normalizedUrl, - ); -} - function moveItem(items: T[], fromIndex: number, toIndex: number): T[] { if (fromIndex === toIndex) return items; const nextItems = [...items]; @@ -58,6 +112,332 @@ function moveItem(items: T[], fromIndex: number, toIndex: number): T[] { return nextItems; } +function findNodeById( + nodes: BrowserBookmarkTreeNode[], + nodeId: string, +): BrowserBookmarkTreeNode | undefined { + for (const node of nodes) { + if (node.id === nodeId) return node; + if (isBrowserBookmarkFolder(node)) { + const childMatch = findNodeById(node.children, nodeId); + if (childMatch) return childMatch; + } + } + return undefined; +} + +export function findBookmarkByUrl( + nodes: BrowserBookmarkTreeNode[], + url: string, +): BrowserBookmark | undefined { + const normalizedUrl = normalizeBookmarkUrl(url); + for (const node of nodes) { + if (isBrowserBookmark(node)) { + if (normalizeBookmarkUrl(node.url) === normalizedUrl) { + return node; + } + continue; + } + const childMatch = findBookmarkByUrl(node.children, normalizedUrl); + if (childMatch) return childMatch; + } + return undefined; +} + +function findBookmarkByUrlExcludingId( + nodes: BrowserBookmarkTreeNode[], + url: string, + excludedId?: string, +): BrowserBookmark | undefined { + const normalizedUrl = normalizeBookmarkUrl(url); + for (const node of nodes) { + if (isBrowserBookmark(node)) { + if ( + node.id !== excludedId && + normalizeBookmarkUrl(node.url) === normalizedUrl + ) { + return node; + } + continue; + } + const childMatch = findBookmarkByUrlExcludingId( + node.children, + normalizedUrl, + excludedId, + ); + if (childMatch) return childMatch; + } + return undefined; +} + +export function findBookmarkParentFolderId( + nodes: BrowserBookmarkTreeNode[], + bookmarkId: string, + parentFolderId: string | null = null, +): string | null { + for (const node of nodes) { + if (isBrowserBookmark(node)) { + if (node.id === bookmarkId) return parentFolderId; + continue; + } + const childMatch = findBookmarkParentFolderId( + node.children, + bookmarkId, + node.id, + ); + if (childMatch !== null) return childMatch; + } + return null; +} + +export function getBookmarkFolderOptions( + nodes: BrowserBookmarkTreeNode[], + parentTitles: string[] = [], +): FolderOption[] { + return nodes.flatMap((node) => { + if (!isBrowserBookmarkFolder(node)) return []; + const titles = [...parentTitles, node.title.trim() || "Untitled Folder"]; + return [ + { id: node.id, label: titles.join(" / ") }, + ...getBookmarkFolderOptions(node.children, titles), + ]; + }); +} + +export function folderContainsBookmarkUrl( + folder: BrowserBookmarkFolder, + url: string, +): boolean { + const normalizedUrl = normalizeBookmarkUrl(url); + return folder.children.some((node) => { + if (isBrowserBookmark(node)) { + return normalizeBookmarkUrl(node.url) === normalizedUrl; + } + return folderContainsBookmarkUrl(node, normalizedUrl); + }); +} + +function removeNodeFromTree( + nodes: BrowserBookmarkTreeNode[], + nodeId: string, +): { nodes: BrowserBookmarkTreeNode[]; removed?: BrowserBookmarkTreeNode } { + let removed: BrowserBookmarkTreeNode | undefined; + const nextNodes: BrowserBookmarkTreeNode[] = []; + + for (const node of nodes) { + if (node.id === nodeId) { + removed = node; + continue; + } + + if (isBrowserBookmarkFolder(node)) { + const childResult = removeNodeFromTree(node.children, nodeId); + if (childResult.removed) { + removed = childResult.removed; + nextNodes.push({ ...node, children: childResult.nodes }); + continue; + } + } + + nextNodes.push(node); + } + + return { nodes: nextNodes, removed }; +} + +function insertNodeIntoFolder( + nodes: BrowserBookmarkTreeNode[], + nodeToInsert: BrowserBookmarkTreeNode, + folderId: string, +): { nodes: BrowserBookmarkTreeNode[]; inserted: boolean } { + let inserted = false; + const nextNodes = nodes.map((node) => { + if (!isBrowserBookmarkFolder(node)) { + return node; + } + + if (node.id === folderId) { + inserted = true; + return { + ...node, + children: [...node.children, nodeToInsert], + }; + } + + const childResult = insertNodeIntoFolder( + node.children, + nodeToInsert, + folderId, + ); + if (childResult.inserted) { + inserted = true; + return { + ...node, + children: childResult.nodes, + }; + } + + return node; + }); + + return { nodes: nextNodes, inserted }; +} + +function reorderFolderChildrenInTree( + nodes: BrowserBookmarkTreeNode[], + folderId: string, + activeId: string, + overId: string, +): { nodes: BrowserBookmarkTreeNode[]; reordered: boolean } { + let reordered = false; + + const nextNodes = nodes.map((node) => { + if (!isBrowserBookmarkFolder(node)) { + return node; + } + + if (node.id === folderId) { + const fromIndex = node.children.findIndex( + (child) => child.id === activeId, + ); + const toIndex = node.children.findIndex((child) => child.id === overId); + if (fromIndex < 0 || toIndex < 0) { + return node; + } + + reordered = true; + return { + ...node, + children: moveItem(node.children, fromIndex, toIndex), + }; + } + + const childResult = reorderFolderChildrenInTree( + node.children, + folderId, + activeId, + overId, + ); + if (!childResult.reordered) { + return node; + } + + reordered = true; + return { + ...node, + children: childResult.nodes, + }; + }); + + return { nodes: nextNodes, reordered }; +} + +function sanitizeLegacyNodes(value: unknown): BrowserBookmarkTreeNode[] { + if (!Array.isArray(value)) return []; + + return value.flatMap((entry): BrowserBookmarkTreeNode[] => { + if (!isRecord(entry)) return []; + + const title = + typeof entry.title === "string" && entry.title.trim() + ? entry.title.trim() + : "Untitled"; + const createdAt = + typeof entry.createdAt === "number" ? entry.createdAt : Date.now(); + const id = + typeof entry.id === "string" && entry.id ? entry.id : crypto.randomUUID(); + + if (entry.type === "folder") { + return [ + { + id, + type: "folder" as const, + title, + iconKey: isBrowserBookmarkFolderIconKey(entry.iconKey) + ? entry.iconKey + : undefined, + color: typeof entry.color === "string" ? entry.color : null, + createdAt, + children: sanitizeLegacyNodes(entry.children), + }, + ]; + } + + const normalizedUrl = normalizeBookmarkUrl( + typeof entry.url === "string" ? entry.url : "", + ); + if (!normalizedUrl || normalizedUrl === "about:blank") { + return []; + } + + return [ + { + id, + type: "bookmark" as const, + url: normalizedUrl, + title: title === "Untitled" ? normalizedUrl : title, + faviconUrl: + typeof entry.faviconUrl === "string" ? entry.faviconUrl : undefined, + createdAt, + }, + ]; + }); +} + +function cloneImportedNodes(nodes: BrowserBookmarkTreeNode[]): { + nodes: BrowserBookmarkTreeNode[]; + bookmarksAdded: number; + foldersAdded: number; + skipped: number; +} { + let bookmarksAdded = 0; + let foldersAdded = 0; + let skipped = 0; + + const clonedNodes: BrowserBookmarkTreeNode[] = nodes.flatMap( + (node): BrowserBookmarkTreeNode[] => { + if (isBrowserBookmark(node)) { + const normalizedUrl = normalizeBookmarkUrl(node.url); + if (!normalizedUrl || normalizedUrl === "about:blank") { + skipped += 1; + return []; + } + + bookmarksAdded += 1; + return [ + { + id: crypto.randomUUID(), + type: "bookmark" as const, + url: normalizedUrl, + title: node.title.trim() || normalizedUrl, + faviconUrl: node.faviconUrl, + createdAt: Date.now(), + }, + ]; + } + + const nestedResult = cloneImportedNodes(node.children); + bookmarksAdded += nestedResult.bookmarksAdded; + foldersAdded += nestedResult.foldersAdded + 1; + skipped += nestedResult.skipped; + + return [ + { + id: crypto.randomUUID(), + type: "folder" as const, + title: node.title.trim() || "Untitled Folder", + iconKey: node.iconKey, + color: node.color ?? null, + createdAt: Date.now(), + children: nestedResult.nodes, + }, + ]; + }, + ); + + return { nodes: clonedNodes, bookmarksAdded, foldersAdded, skipped }; +} + export const useBrowserBookmarksStore = create()( devtools( persist( @@ -70,60 +450,103 @@ export const useBrowserBookmarksStore = create()( return null; } - const title = bookmark.title.trim() || normalizedUrl; - const existingIndex = findBookmarkIndex( + const existingBookmark = findBookmarkByUrl( get().bookmarks, normalizedUrl, ); - if (existingIndex >= 0) { - const existingBookmark = get().bookmarks[existingIndex]; - if (!existingBookmark) return null; - const updatedBookmark = { - ...existingBookmark, - url: normalizedUrl, - title, - faviconUrl: bookmark.faviconUrl ?? existingBookmark.faviconUrl, - }; - set((state) => ({ - bookmarks: state.bookmarks.map((entry, index) => - index === existingIndex ? updatedBookmark : entry, - ), - })); - return updatedBookmark; + if (existingBookmark) { + return existingBookmark; } const nextBookmark: BrowserBookmark = { id: crypto.randomUUID(), + type: "bookmark", url: normalizedUrl, - title, + title: bookmark.title.trim() || normalizedUrl, faviconUrl: bookmark.faviconUrl, createdAt: Date.now(), }; - set((state) => ({ - bookmarks: [...state.bookmarks, nextBookmark], - })); + set((state) => { + if (!bookmark.folderId) { + return { bookmarks: [...state.bookmarks, nextBookmark] }; + } + + const inserted = insertNodeIntoFolder( + state.bookmarks, + nextBookmark, + bookmark.folderId, + ); + + return { + bookmarks: inserted.inserted + ? inserted.nodes + : [...state.bookmarks, nextBookmark], + }; + }); return nextBookmark; }, + duplicateBookmark: (bookmarkId) => { + const targetBookmark = findNodeById(get().bookmarks, bookmarkId); + if (!targetBookmark || !isBrowserBookmark(targetBookmark)) { + return null; + } + + const duplicatedBookmark: BrowserBookmark = { + ...targetBookmark, + id: crypto.randomUUID(), + title: `${targetBookmark.title.trim() || targetBookmark.url} (Copy)`, + createdAt: Date.now(), + }; + const folderId = findBookmarkParentFolderId( + get().bookmarks, + bookmarkId, + ); + + set((state) => { + if (!folderId) { + return { + bookmarks: [...state.bookmarks, duplicatedBookmark], + }; + } + + const inserted = insertNodeIntoFolder( + state.bookmarks, + duplicatedBookmark, + folderId, + ); + + return { + bookmarks: inserted.inserted + ? inserted.nodes + : [...state.bookmarks, duplicatedBookmark], + }; + }); + + return duplicatedBookmark; + }, + updateBookmark: (bookmarkId, bookmark) => { const normalizedUrl = normalizeBookmarkUrl(bookmark.url); if (!normalizedUrl || normalizedUrl === "about:blank") { return null; } - const targetBookmark = get().bookmarks.find( - (entry) => entry.id === bookmarkId, - ); - if (!targetBookmark) return null; + const targetBookmark = findNodeById(get().bookmarks, bookmarkId); + if (!targetBookmark || !isBrowserBookmark(targetBookmark)) { + return null; + } - const duplicateBookmark = get().bookmarks.find( - (entry) => - entry.id !== bookmarkId && - normalizeBookmarkUrl(entry.url) === normalizedUrl, - ); - if (duplicateBookmark) { + if ( + normalizeBookmarkUrl(targetBookmark.url) !== normalizedUrl && + findBookmarkByUrlExcludingId( + get().bookmarks, + normalizedUrl, + bookmarkId, + ) + ) { return null; } @@ -134,31 +557,113 @@ export const useBrowserBookmarksStore = create()( faviconUrl: bookmark.faviconUrl ?? targetBookmark.faviconUrl, }; + set((state) => { + const removed = removeNodeFromTree(state.bookmarks, bookmarkId); + let nextNodes = removed.nodes; + + if (bookmark.folderId) { + const inserted = insertNodeIntoFolder( + nextNodes, + updatedBookmark, + bookmark.folderId, + ); + nextNodes = inserted.inserted + ? inserted.nodes + : [...nextNodes, updatedBookmark]; + } else { + nextNodes = [...nextNodes, updatedBookmark]; + } + + return { bookmarks: nextNodes }; + }); + + return updatedBookmark; + }, + + addFolder: (folder) => { + const nextFolder: BrowserBookmarkFolder = { + id: crypto.randomUUID(), + type: "folder", + title: folder.title.trim() || "Untitled Folder", + iconKey: folder.iconKey, + color: folder.color ?? null, + children: [], + createdAt: Date.now(), + }; + set((state) => ({ - bookmarks: state.bookmarks.map((entry) => - entry.id === bookmarkId ? updatedBookmark : entry, - ), + bookmarks: [...state.bookmarks, nextFolder], })); - return updatedBookmark; + return nextFolder; + }, + + updateFolder: (folderId, folder) => { + const targetNode = findNodeById(get().bookmarks, folderId); + if (!targetNode || !isBrowserBookmarkFolder(targetNode)) { + return null; + } + + const updatedFolder: BrowserBookmarkFolder = { + ...targetNode, + title: folder.title.trim() || "Untitled Folder", + iconKey: folder.iconKey, + color: folder.color ?? null, + }; + + const replaceFolder = ( + nodes: BrowserBookmarkTreeNode[], + ): BrowserBookmarkTreeNode[] => + nodes.map((node) => { + if (node.id === folderId && isBrowserBookmarkFolder(node)) { + return updatedFolder; + } + if (isBrowserBookmarkFolder(node)) { + return { + ...node, + children: replaceFolder(node.children), + }; + } + return node; + }); + + set((state) => ({ + bookmarks: replaceFolder(state.bookmarks), + })); + + return updatedFolder; + }, + + reorderFolderChildren: (folderId, activeId, overId) => { + if (activeId === overId) return; + set((state) => { + const reordered = reorderFolderChildrenInTree( + state.bookmarks, + folderId, + activeId, + overId, + ); + if (!reordered.reordered) { + return state; + } + return { bookmarks: reordered.nodes }; + }); }, - removeBookmark: (bookmarkId) => { + removeNode: (nodeId) => { set((state) => ({ - bookmarks: state.bookmarks.filter( - (bookmark) => bookmark.id !== bookmarkId, - ), + bookmarks: removeNodeFromTree(state.bookmarks, nodeId).nodes, })); }, - moveBookmark: (activeId, overId) => { + moveNode: (activeId, overId) => { if (activeId === overId) return; set((state) => { const fromIndex = state.bookmarks.findIndex( - (bookmark) => bookmark.id === activeId, + (node) => node.id === activeId, ); const toIndex = state.bookmarks.findIndex( - (bookmark) => bookmark.id === overId, + (node) => node.id === overId, ); if (fromIndex < 0 || toIndex < 0) return state; return { @@ -168,24 +673,40 @@ export const useBrowserBookmarksStore = create()( }, toggleBookmark: (bookmark) => { - const existingIndex = findBookmarkIndex( + const existingBookmark = findBookmarkByUrl( get().bookmarks, bookmark.url, ); - if (existingIndex >= 0) { - const existingBookmark = get().bookmarks[existingIndex]; - if (existingBookmark) { - get().removeBookmark(existingBookmark.id); - } + if (existingBookmark) { + get().removeNode(existingBookmark.id); return false; } return get().addBookmark(bookmark) !== null; }, + + importBookmarks: (nodes) => { + const result = cloneImportedNodes(nodes); + set((state) => ({ + bookmarks: [...state.bookmarks, ...result.nodes], + })); + return { + bookmarksAdded: result.bookmarksAdded, + foldersAdded: result.foldersAdded, + skipped: result.skipped, + }; + }, }), { name: "browser-bookmarks-store", - version: 1, + version: 3, + migrate: (persistedState) => { + if (!isRecord(persistedState)) return { bookmarks: [] }; + return { + ...persistedState, + bookmarks: sanitizeLegacyNodes(persistedState.bookmarks), + }; + }, }, ), { name: "BrowserBookmarksStore" }, From 4739f0c864d5c7b50428a80e791d47ec0e66f6a1 Mon Sep 17 00:00:00 2001 From: MocA-Love Date: Fri, 3 Apr 2026 16:03:00 +0900 Subject: [PATCH 2/2] fix(desktop): harden bookmark folder workflows --- .../src/lib/trpc/routers/external/index.ts | 29 +++++-- .../BookmarkFolderItem/BookmarkFolderItem.tsx | 81 +++++++++++++++---- .../BrowserOverflowMenu.tsx | 76 ++++++++++------- .../src/renderer/stores/browser-bookmarks.ts | 4 +- 4 files changed, 136 insertions(+), 54 deletions(-) diff --git a/apps/desktop/src/lib/trpc/routers/external/index.ts b/apps/desktop/src/lib/trpc/routers/external/index.ts index 036a754f1d7..1b35599162a 100644 --- a/apps/desktop/src/lib/trpc/routers/external/index.ts +++ b/apps/desktop/src/lib/trpc/routers/external/index.ts @@ -231,11 +231,19 @@ export const createExternalRouter = () => { return null; } - const content = await readFile(filePath, "utf-8"); - return { - path: filePath, - content, - }; + try { + const content = await readFile(filePath, "utf-8"); + return { + path: filePath, + content, + }; + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to read file: ${filePath}`, + cause: error, + }); + } }), saveTextFile: publicProcedure @@ -264,7 +272,16 @@ export const createExternalRouter = () => { return null; } - await writeFile(result.filePath, input.content, "utf-8"); + try { + await writeFile(result.filePath, input.content, "utf-8"); + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Failed to write file: ${result.filePath}`, + cause: error, + }); + } + return { path: result.filePath, }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkFolderItem/BookmarkFolderItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkFolderItem/BookmarkFolderItem.tsx index 3ef0bc667a6..5c2e358ea9b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkFolderItem/BookmarkFolderItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BookmarkBar/components/BookmarkFolderItem/BookmarkFolderItem.tsx @@ -27,7 +27,7 @@ import { toast } from "@superset/ui/sonner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { GripVerticalIcon } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { getBrowserBookmarkFolderIcon } from "renderer/stores/browser-bookmark-folder-icons"; import { type BrowserBookmarkFolder, @@ -56,6 +56,69 @@ interface FolderTreeSectionProps { depth?: number; } +interface SortableFolderTreeNodeProps { + folder: BrowserBookmarkFolder; + depth: number; + children: ReactNode; +} + +function SortableFolderTreeNode({ + folder, + depth, + children, +}: SortableFolderTreeNodeProps) { + const FolderIcon = getBrowserBookmarkFolderIcon(folder.iconKey); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: folder.id, + }); + + const style = useMemo( + () => ({ + transform: CSS.Transform.toString( + transform ? { ...transform, x: 0 } : null, + ), + transition, + }), + [transform, transition], + ); + + return ( +

+
+ + {folder.title} + +
+ {children} +
+ ); +} + function FolderTreeSection({ folderId, nodes, @@ -116,20 +179,8 @@ function FolderTreeSection({ ); } - const NestedFolderIcon = getBrowserBookmarkFolderIcon(node.iconKey); - return ( -
-
- - {node.title} -
+ {node.children.length > 0 ? ( ) : null} -
+ ); })} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserToolbar/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserToolbar/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx index 377075e1713..999c0cd8422 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserToolbar/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserToolbar/components/BrowserOverflowMenu/BrowserOverflowMenu.tsx @@ -91,44 +91,58 @@ export function BrowserOverflowMenu({ }; const handleImportBookmarks = async () => { - const file = await openTextFileMutation.mutateAsync({ - title: "Import Bookmarks", - buttonLabel: "Import", - filters: [{ name: "Bookmarks HTML", extensions: ["html", "htm"] }], - }); - - if (!file) { - return; - } + try { + const file = await openTextFileMutation.mutateAsync({ + title: "Import Bookmarks", + buttonLabel: "Import", + filters: [{ name: "Bookmarks HTML", extensions: ["html", "htm"] }], + }); + + if (!file) { + return; + } - const importedNodes = importBrowserBookmarksFromHtml(file.content); - const result = importBookmarks(importedNodes); + const importedNodes = importBrowserBookmarksFromHtml(file.content); + const result = importBookmarks(importedNodes); - if (result.bookmarksAdded === 0 && result.foldersAdded === 0) { - toast.error("No bookmarks were imported"); - return; - } + if (result.bookmarksAdded === 0 && result.foldersAdded === 0) { + toast.error("No bookmarks were imported"); + return; + } - toast.success( - `Imported ${result.bookmarksAdded} bookmarks and ${result.foldersAdded} folders`, - ); + toast.success( + `Imported ${result.bookmarksAdded} bookmarks and ${result.foldersAdded} folders`, + ); + } catch (error) { + console.error("[browser-bookmarks/import]", error); + toast.error("Failed to import bookmarks", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } }; const handleExportBookmarks = async () => { - const content = exportBrowserBookmarksToHtml(bookmarks); - const saved = await saveTextFileMutation.mutateAsync({ - title: "Export Bookmarks", - defaultPath: "bookmarks.html", - buttonLabel: "Export", - filters: [{ name: "Bookmarks HTML", extensions: ["html"] }], - content, - }); - - if (!saved) { - return; - } + try { + const content = exportBrowserBookmarksToHtml(bookmarks); + const saved = await saveTextFileMutation.mutateAsync({ + title: "Export Bookmarks", + defaultPath: "bookmarks.html", + buttonLabel: "Export", + filters: [{ name: "Bookmarks HTML", extensions: ["html"] }], + content, + }); + + if (!saved) { + return; + } - toast.success("Bookmarks exported"); + toast.success("Bookmarks exported"); + } catch (error) { + console.error("[browser-bookmarks/export]", error); + toast.error("Failed to export bookmarks", { + description: error instanceof Error ? error.message : "Unknown error", + }); + } }; const handleClearCookies = () => { diff --git a/apps/desktop/src/renderer/stores/browser-bookmarks.ts b/apps/desktop/src/renderer/stores/browser-bookmarks.ts index acce3866734..2153e2d08b3 100644 --- a/apps/desktop/src/renderer/stores/browser-bookmarks.ts +++ b/apps/desktop/src/renderer/stores/browser-bookmarks.ts @@ -411,7 +411,7 @@ function cloneImportedNodes(nodes: BrowserBookmarkTreeNode[]): { url: normalizedUrl, title: node.title.trim() || normalizedUrl, faviconUrl: node.faviconUrl, - createdAt: Date.now(), + createdAt: node.createdAt || Date.now(), }, ]; } @@ -428,7 +428,7 @@ function cloneImportedNodes(nodes: BrowserBookmarkTreeNode[]): { title: node.title.trim() || "Untitled Folder", iconKey: node.iconKey, color: node.color ?? null, - createdAt: Date.now(), + createdAt: node.createdAt || Date.now(), children: nestedResult.nodes, }, ];