From c86af4e1cedaf2c908521daf3dab596c6e30ca3c Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Tue, 14 Apr 2026 19:23:05 -0700 Subject: [PATCH] feat(desktop): add Recently Viewed section to v2 Quick Open Pins recently-opened files at the top of the v2 workspace command palette, with currently-open tabs preferred. List is persisted per-workspace via the existing v2WorkspaceLocalState localStorage collection (cap 25 stored, 10 displayed). Empty query shows only the section; non-empty query filters recents by substring match and dedupes them out of the fuzzy results. Extracts the duplicated row markup into a shared FileResultItem component. --- .../hooks/useRecentlyViewedFiles/constants.ts | 2 + .../hooks/useRecentlyViewedFiles/index.ts | 6 + .../useRecentlyViewedFiles.ts | 59 ++++++++++ .../v2-workspace/$workspaceId/page.tsx | 38 +++++- .../dashboardSidebarLocal/schema.ts | 9 ++ .../CommandPalette/CommandPalette.tsx | 109 ++++++++++++++---- .../FileResultItem/FileResultItem.tsx | 33 ++++++ .../components/FileResultItem/index.ts | 1 + bun.lock | 2 +- 9 files changed, 234 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/constants.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/useRecentlyViewedFiles.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/FileResultItem.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/constants.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/constants.ts new file mode 100644 index 00000000000..3f02e9c6a05 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/constants.ts @@ -0,0 +1,2 @@ +export const RECENT_STORE_LIMIT = 25; +export const RECENT_DISPLAY_LIMIT = 10; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/index.ts new file mode 100644 index 00000000000..a6713ecc018 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/index.ts @@ -0,0 +1,6 @@ +export { RECENT_DISPLAY_LIMIT, RECENT_STORE_LIMIT } from "./constants"; +export { + type RecentFile, + type RecentlyViewedFilesApi, + useRecentlyViewedFiles, +} from "./useRecentlyViewedFiles"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/useRecentlyViewedFiles.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/useRecentlyViewedFiles.ts new file mode 100644 index 00000000000..f9a36b31c68 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles/useRecentlyViewedFiles.ts @@ -0,0 +1,59 @@ +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCallback, useMemo } from "react"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; +import { RECENT_STORE_LIMIT } from "./constants"; + +export interface RecentFile { + relativePath: string; + absolutePath: string; + lastAccessedAt: number; +} + +interface RecentFileInput { + relativePath: string; + absolutePath: string; +} + +export interface RecentlyViewedFilesApi { + recentFiles: RecentFile[]; + recordView: (file: RecentFileInput) => void; +} + +export function useRecentlyViewedFiles( + workspaceId: string, +): RecentlyViewedFilesApi { + const collections = useCollections(); + + const { data: rows = [] } = useLiveQuery( + (query) => + query + .from({ state: collections.v2WorkspaceLocalState }) + .where(({ state }) => eq(state.workspaceId, workspaceId)), + [collections, workspaceId], + ); + const recentFiles = useMemo(() => rows[0]?.recentlyViewedFiles ?? [], [rows]); + + const recordView = useCallback( + (file: RecentFileInput) => { + if (!collections.v2WorkspaceLocalState.get(workspaceId)) return; + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + const current = draft.recentlyViewedFiles ?? []; + const withoutDup = current.filter( + (f) => f.relativePath !== file.relativePath, + ); + draft.recentlyViewedFiles = [ + { + relativePath: file.relativePath, + absolutePath: file.absolutePath, + lastAccessedAt: Date.now(), + }, + ...withoutDup, + ].slice(0, RECENT_STORE_LIMIT); + }); + }, + [collections, workspaceId], + ); + + return { recentFiles, recordView }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 62c18d0bb07..c64fa153cfc 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -5,6 +5,7 @@ import { ResizablePanel, ResizablePanelGroup, } from "@superset/ui/resizable"; +import { workspaceTrpc } from "@superset/workspace-client"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { createFileRoute } from "@tanstack/react-router"; @@ -14,6 +15,10 @@ import { TbLayoutColumns, TbLayoutRows } from "react-icons/tb"; import { HotkeyLabel, useHotkey } from "renderer/hotkeys"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import { CommandPalette } from "renderer/screens/main/components/CommandPalette"; +import { + toAbsoluteWorkspacePath, + toRelativeWorkspacePath, +} from "shared/absolute-paths"; import { useStore } from "zustand"; import { AddTabMenu } from "./components/AddTabMenu"; import { V2PresetsBar } from "./components/V2PresetsBar"; @@ -23,6 +28,7 @@ import { WorkspaceSidebar } from "./components/WorkspaceSidebar"; import { useDefaultContextMenuActions } from "./hooks/useDefaultContextMenuActions"; import { usePaneRegistry } from "./hooks/usePaneRegistry"; import { renderBrowserTabIcon } from "./hooks/usePaneRegistry/components/BrowserPane"; +import { useRecentlyViewedFiles } from "./hooks/useRecentlyViewedFiles"; import { useV2PresetExecution } from "./hooks/useV2PresetExecution"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; import { useWorkspaceHotkeys } from "./hooks/useWorkspaceHotkeys"; @@ -93,6 +99,13 @@ function WorkspaceContent({ const paneRegistry = usePaneRegistry(workspaceId); const defaultContextMenuActions = useDefaultContextMenuActions(paneRegistry); + const workspaceQuery = workspaceTrpc.workspace.get.useQuery({ + id: workspaceId, + }); + const worktreePath = workspaceQuery.data?.worktreePath ?? ""; + + const { recentFiles, recordView } = useRecentlyViewedFiles(workspaceId); + const selectedFilePath = useStore(store, (s) => { const tab = s.tabs.find((t) => t.id === s.activeTabId); if (!tab?.activePaneId) return undefined; @@ -101,8 +114,29 @@ function WorkspaceContent({ return undefined; }); + const openFilePathsKey = useStore(store, (s) => + s.tabs + .flatMap((t) => + Object.values(t.panes) + .filter((p) => p.kind === "file") + .map((p) => (p.data as FilePaneData).filePath), + ) + .join("\u0000"), + ); + const openFilePaths = useMemo( + () => new Set(openFilePathsKey ? openFilePathsKey.split("\u0000") : []), + [openFilePathsKey], + ); + const openFilePane = useCallback( (filePath: string, openInNewTab?: boolean) => { + if (worktreePath) { + const absolutePath = toAbsoluteWorkspacePath(worktreePath, filePath); + const relativePath = toRelativeWorkspacePath(worktreePath, filePath); + if (relativePath && relativePath !== ".") { + recordView({ relativePath, absolutePath }); + } + } const state = store.getState(); if (openInNewTab) { state.addTab({ @@ -138,7 +172,7 @@ function WorkspaceContent({ }, }); }, - [store], + [store, worktreePath, recordView], ); const openDiffPane = useCallback( @@ -381,6 +415,8 @@ function WorkspaceContent({ onOpenChange={setQuickOpenOpen} onSelectFile={openFilePane} variant="v2" + recentlyViewedFiles={recentFiles} + openFilePaths={openFilePaths} /> ); diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts index d715b8b2cc0..7cabddc08c5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal/schema.ts @@ -41,6 +41,15 @@ export const workspaceLocalStateSchema = z.object({ paneLayout: paneWorkspaceStateSchema, rightSidebarOpen: z.boolean().default(false), viewedFiles: z.array(z.string()).default([]), + recentlyViewedFiles: z + .array( + z.object({ + relativePath: z.string(), + absolutePath: z.string(), + lastAccessedAt: z.number(), + }), + ) + .default([]), }); export const dashboardSidebarSectionSchema = z.object({ diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/CommandPalette.tsx b/apps/desktop/src/renderer/screens/main/components/CommandPalette/CommandPalette.tsx index c106fc9144e..28c0f12ce7a 100644 --- a/apps/desktop/src/renderer/screens/main/components/CommandPalette/CommandPalette.tsx +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/CommandPalette.tsx @@ -1,10 +1,12 @@ import * as DialogPrimitive from "@radix-ui/react-dialog"; -import { CommandPrimitive } from "@superset/ui/command"; +import { CommandPrimitive, CommandSeparator } from "@superset/ui/command"; import { SearchIcon } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { LuChevronDown, LuChevronRight } from "react-icons/lu"; +import type { RecentFile } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles"; +import { RECENT_DISPLAY_LIMIT } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useRecentlyViewedFiles"; import { useFileSearch } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/hooks/useFileSearch/useFileSearch"; -import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; +import { FileResultItem } from "./components/FileResultItem"; import { useV2FileSearch } from "./hooks/useV2FileSearch"; // 48px input + 10 * 40px items @@ -17,6 +19,13 @@ export interface CommandPaletteProps { onOpenChange: (open: boolean) => void; onSelectFile: (filePath: string) => void; variant?: "v1" | "v2"; + recentlyViewedFiles?: RecentFile[]; + openFilePaths?: Set; +} + +function getFileName(relativePath: string): string { + const segments = relativePath.split("/"); + return segments[segments.length - 1] ?? relativePath; } export function CommandPalette({ @@ -25,6 +34,8 @@ export function CommandPalette({ onOpenChange, onSelectFile, variant = "v1", + recentlyViewedFiles, + openFilePaths, }: CommandPaletteProps) { const [query, setQuery] = useState(""); const [filtersOpen, setFiltersOpen] = useState(false); @@ -45,7 +56,45 @@ export function CommandPalette({ variant === "v2" ? query : "", ); - const results = variant === "v2" ? v2Search.results : v1Search.searchResults; + const rawResults = + variant === "v2" ? v2Search.results : v1Search.searchResults; + const trimmedQuery = query.trim(); + const hasQuery = trimmedQuery.length > 0; + const showRecentSection = variant === "v2" && Boolean(recentlyViewedFiles); + + const orderedRecent = useMemo(() => { + if (!showRecentSection || !recentlyViewedFiles) return []; + const openSet = openFilePaths ?? new Set(); + const openFiles: RecentFile[] = []; + const rest: RecentFile[] = []; + for (const file of recentlyViewedFiles) { + if (openSet.has(file.absolutePath)) { + openFiles.push(file); + } else { + rest.push(file); + } + } + return [...openFiles, ...rest].slice(0, RECENT_DISPLAY_LIMIT); + }, [showRecentSection, recentlyViewedFiles, openFilePaths]); + + const filteredRecent = useMemo(() => { + if (!showRecentSection) return []; + if (!hasQuery) return orderedRecent; + const needle = trimmedQuery.toLowerCase(); + return orderedRecent.filter((file) => + file.relativePath.toLowerCase().includes(needle), + ); + }, [showRecentSection, hasQuery, trimmedQuery, orderedRecent]); + + const recentAbsSet = useMemo( + () => new Set(filteredRecent.map((f) => f.absolutePath)), + [filteredRecent], + ); + + const dedupedResults = useMemo(() => { + if (!showRecentSection) return rawResults; + return rawResults.filter((r) => !recentAbsSet.has(r.path)); + }, [showRecentSection, rawResults, recentAbsSet]); const handleOpenChange = useCallback( (nextOpen: boolean) => { @@ -67,6 +116,12 @@ export function CommandPalette({ if (open) requestAnimationFrame(() => inputRef.current?.focus()); }, [open]); + const showHeading = showRecentSection && filteredRecent.length > 0; + const showSeparator = + showRecentSection && filteredRecent.length > 0 && dedupedResults.length > 0; + const showEmptyState = + filteredRecent.length === 0 && dedupedResults.length === 0; + return ( @@ -129,32 +184,40 @@ export function CommandPalette({ )} - {results.length === 0 && ( + {showEmptyState && ( No files found. )} - {results.map((file) => ( - + Recently Viewed + + )} + + {filteredRecent.map((file) => ( + handleSelectFile(file.absolutePath)} + /> + ))} + + {showSeparator && ( + + )} + + {dedupedResults.map((file) => ( + handleSelectFile(file.path)} - className="group data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-2 text-sm outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" - > - - - {file.name} - - - {file.relativePath} - - - ↵ - - + /> ))} diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/FileResultItem.tsx b/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/FileResultItem.tsx new file mode 100644 index 00000000000..24958da6200 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/FileResultItem.tsx @@ -0,0 +1,33 @@ +import { CommandPrimitive } from "@superset/ui/command"; +import { FileIcon } from "renderer/screens/main/components/WorkspaceView/RightSidebar/FilesView/utils"; + +interface FileResultItemProps { + value: string; + fileName: string; + relativePath: string; + onSelect: () => void; +} + +export function FileResultItem({ + value, + fileName, + relativePath, + onSelect, +}: FileResultItemProps) { + return ( + + + {fileName} + + {relativePath} + + + ↵ + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/index.ts b/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/index.ts new file mode 100644 index 00000000000..c8e5e1947f2 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/CommandPalette/components/FileResultItem/index.ts @@ -0,0 +1 @@ +export { FileResultItem } from "./FileResultItem"; diff --git a/bun.lock b/bun.lock index cde4c6c4a76..4aedae65728 100644 --- a/bun.lock +++ b/bun.lock @@ -110,7 +110,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "1.5.3", + "version": "1.5.5", "dependencies": { "@ai-sdk/anthropic": "^3.0.43", "@ai-sdk/openai": "3.0.36",