diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 58b665ec379..efc3e8bf960 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -49,6 +49,7 @@ "framer-motion": "^12.23.24", "http-proxy": "^1.18.1", "lowdb": "^7.0.1", + "nanoid": "^5.1.6", "node-pty": "1.1.0-beta30", "react": "^19.1.1", "react-arborist": "^3.4.3", @@ -63,6 +64,7 @@ "superjson": "^2.2.5", "tailwind-merge": "^2.6.0", "trpc-electron": "^0.1.2", + "zod": "^4.1.12", "zustand": "^5.0.8" }, "devDependencies": { diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index a2d8bda9445..263d30d3cf6 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -1,6 +1,8 @@ import type { BrowserWindow } from "electron"; import { router } from ".."; import { createWindowRouter } from "./window"; +import { createProjectsRouter } from "./projects"; +import { createWorkspacesRouter } from "./workspaces"; /** * Main application router @@ -9,6 +11,8 @@ import { createWindowRouter } from "./window"; export const createAppRouter = (window: BrowserWindow) => { return router({ window: createWindowRouter(window), + projects: createProjectsRouter(window), + workspaces: createWorkspacesRouter(), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/projects/index.ts b/apps/desktop/src/lib/trpc/routers/projects/index.ts new file mode 100644 index 00000000000..233cf2f51bf --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/projects/index.ts @@ -0,0 +1,2 @@ +export { createProjectsRouter } from "./projects"; +export type { ProjectsRouter } from "./projects"; diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts new file mode 100644 index 00000000000..84604fd27f1 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -0,0 +1,91 @@ +import { dialog } from "electron"; +import type { BrowserWindow } from "electron"; +import { basename } from "node:path"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; +import { db } from "../../../../main/lib/db"; +import type { RecentProject } from "../../../../main/lib/db/schemas"; + +/** + * Projects router + * Handles project selection, recents management, and workspace creation + */ +export const createProjectsRouter = (window: BrowserWindow) => { + return router({ + /** + * Open a new project via folder picker + * Adds to recents and returns path for UI to handle + */ + openProject: publicProcedure.mutation(async () => { + const result = await dialog.showOpenDialog(window, { + properties: ["openDirectory"], + title: "Open Project", + }); + + if (result.canceled || result.filePaths.length === 0) { + return { success: false as const }; + } + + const path = result.filePaths[0]; + const name = basename(path); + + await db.update((data) => { + const existingIndex = data.recentProjects.findIndex( + (p) => p.path === path, + ); + if (existingIndex !== -1) { + data.recentProjects[existingIndex].lastOpenedAt = Date.now(); + } else { + data.recentProjects.push({ + path, + name, + lastOpenedAt: Date.now(), + }); + } + }); + + return { + success: true as const, + path, + name, + }; + }), + openRecent: publicProcedure + .input(z.object({ path: z.string() })) + .mutation(async ({ input }) => { + const { path } = input; + const name = basename(path); + + await db.update((data) => { + const recent = data.recentProjects.find((p) => p.path === path); + if (recent) { + recent.lastOpenedAt = Date.now(); + } + }); + + return { + success: true as const, + path, + name, + }; + }), + getRecents: publicProcedure.query((): RecentProject[] => { + return db.data.recentProjects + .slice() + .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); + }), + removeRecent: publicProcedure + .input(z.object({ path: z.string() })) + .mutation(async ({ input }) => { + await db.update((data) => { + data.recentProjects = data.recentProjects.filter( + (p) => p.path !== input.path, + ); + }); + + return { success: true }; + }), + }); +}; + +export type ProjectsRouter = ReturnType; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/index.ts b/apps/desktop/src/lib/trpc/routers/workspaces/index.ts new file mode 100644 index 00000000000..d1bb4aa3ebd --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/index.ts @@ -0,0 +1,2 @@ +export { createWorkspacesRouter } from "./workspaces"; +export type { WorkspacesRouter } from "./workspaces"; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts new file mode 100644 index 00000000000..467d0ca5673 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -0,0 +1,216 @@ +import { z } from "zod"; +import { nanoid } from "nanoid"; +import { publicProcedure, router } from "../.."; +import { db } from "../../../../main/lib/db"; + +/** + * Workspaces router + * Handles workspace CRUD operations + */ +export const createWorkspacesRouter = () => { + return router({ + /** + * Create a new workspace + */ + create: publicProcedure + .input( + z.object({ + name: z.string(), + path: z.string().nullable().optional(), + }), + ) + .mutation(async ({ input }) => { + // Set order to be at the end of the list + const maxOrder = db.data.workspaces.length > 0 + ? Math.max(...db.data.workspaces.map((w) => w.order)) + : -1; + + const workspace = { + id: nanoid(), + name: input.name, + path: input.path ?? null, + order: maxOrder + 1, + createdAt: Date.now(), + updatedAt: Date.now(), + lastOpenedAt: Date.now(), + }; + + await db.update((data) => { + data.workspaces.push(workspace); + data.settings.lastActiveWorkspaceId = workspace.id; + }); + + return workspace; + }), + + /** + * Get a workspace by ID + */ + get: publicProcedure + .input(z.object({ id: z.string() })) + .query(({ input }) => { + const workspace = db.data.workspaces.find((w) => w.id === input.id); + return workspace || null; + }), + + /** + * Get all workspaces sorted by order + */ + getAll: publicProcedure.query(() => { + return db.data.workspaces + .slice() + .sort((a, b) => a.order - b.order); + }), + + /** + * Get the last active workspace + */ + getActive: publicProcedure.query(() => { + const { lastActiveWorkspaceId } = db.data.settings; + + if (!lastActiveWorkspaceId) { + return null; + } + + return db.data.workspaces.find((w) => w.id === lastActiveWorkspaceId) || null; + }), + + /** + * Update a workspace + * Supports partial updates to workspace properties + */ + update: publicProcedure + .input( + z.object({ + id: z.string(), + patch: z.object({ + name: z.string().optional(), + path: z.string().nullable().optional(), + }), + }), + ) + .mutation(async ({ input }) => { + await db.update((data) => { + const workspace = data.workspaces.find((w) => w.id === input.id); + if (!workspace) { + throw new Error(`Workspace ${input.id} not found`); + } + + // Apply patches + if (input.patch.name !== undefined) { + workspace.name = input.patch.name; + } + if (input.patch.path !== undefined) { + workspace.path = input.patch.path; + } + + // Update timestamps + workspace.updatedAt = Date.now(); + workspace.lastOpenedAt = Date.now(); + }); + + return { success: true }; + }), + + /** + * Delete a workspace + * Also removes from recents if no other workspace uses that path + */ + delete: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input }) => { + const workspace = db.data.workspaces.find((w) => w.id === input.id); + + if (!workspace) { + return { success: false, error: "Workspace not found" }; + } + + const workspacePath = workspace.path; + + await db.update((data) => { + // Remove workspace + data.workspaces = data.workspaces.filter((w) => w.id !== input.id); + + // Check if any other workspace uses this path + const otherWorkspaceWithSamePath = data.workspaces.some( + (w) => w.path === workspacePath, + ); + + // If no other workspace uses this path, remove from recents + if (!otherWorkspaceWithSamePath) { + data.recentProjects = data.recentProjects.filter( + (p) => p.path !== workspacePath, + ); + } + + // Update last active workspace if needed + if (data.settings.lastActiveWorkspaceId === input.id) { + // Set to the most recently opened workspace, if any + const sorted = data.workspaces + .slice() + .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); + data.settings.lastActiveWorkspaceId = sorted[0]?.id || undefined; + } + }); + + return { success: true }; + }), + + /** + * Set active workspace + */ + setActive: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input }) => { + await db.update((data) => { + const workspace = data.workspaces.find((w) => w.id === input.id); + if (!workspace) { + throw new Error(`Workspace ${input.id} not found`); + } + + data.settings.lastActiveWorkspaceId = input.id; + workspace.lastOpenedAt = Date.now(); + workspace.updatedAt = Date.now(); + }); + + return { success: true }; + }), + + /** + * Reorder workspaces + */ + reorder: publicProcedure + .input( + z.object({ + fromIndex: z.number(), + toIndex: z.number(), + }), + ) + .mutation(async ({ input }) => { + await db.update((data) => { + const { fromIndex, toIndex } = input; + + // Get all workspaces sorted by order + const workspaces = data.workspaces + .slice() + .sort((a, b) => a.order - b.order); + + // Move workspace from fromIndex to toIndex + const [removed] = workspaces.splice(fromIndex, 1); + workspaces.splice(toIndex, 0, removed); + + // Update order fields to reflect new positions + workspaces.forEach((workspace, index) => { + const ws = data.workspaces.find((w) => w.id === workspace.id); + if (ws) { + ws.order = index; + } + }); + }); + + return { success: true }; + }), + }); +}; + +export type WorkspacesRouter = ReturnType; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index bf0ccab8a89..37fa9676e67 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -3,6 +3,7 @@ import { app } from "electron"; import { makeAppSetup } from "lib/electron-app/factories/app/setup"; import { registerStorageHandlers } from "./lib/storage-ipcs"; import { MainWindow } from "./windows/main"; +import { initDb } from "./lib/db"; // Protocol scheme for deep linking const PROTOCOL_SCHEME = "superset"; @@ -31,6 +32,9 @@ registerStorageHandlers(); (async () => { await app.whenReady(); + // Initialize database + await initDb(); + await makeAppSetup(() => MainWindow()); // Stop all periodic rescans when app is quitting diff --git a/apps/desktop/src/main/lib/db/index.ts b/apps/desktop/src/main/lib/db/index.ts new file mode 100644 index 00000000000..a01bc8e1664 --- /dev/null +++ b/apps/desktop/src/main/lib/db/index.ts @@ -0,0 +1,26 @@ +import { JSONFilePreset } from "lowdb/node"; +import { join } from "node:path"; +import { app } from "electron"; +import type { Database } from "./schemas"; +import { defaultDatabase } from "./schemas"; + +type DB = Awaited>>; + +let _db: DB | null = null; + +export async function initDb(): Promise { + if (_db) return; + + const dbPath = join(app.getPath("userData"), "db.json"); + _db = await JSONFilePreset(dbPath, defaultDatabase); + console.log(`Database initialized at: ${dbPath}`); +} + +export const db = new Proxy({} as DB, { + get(_target, prop) { + if (!_db) { + throw new Error("Database not initialized. Call initDb() first."); + } + return _db[prop as keyof DB]; + }, +}); diff --git a/apps/desktop/src/main/lib/db/schemas.ts b/apps/desktop/src/main/lib/db/schemas.ts new file mode 100644 index 00000000000..8964461332a --- /dev/null +++ b/apps/desktop/src/main/lib/db/schemas.ts @@ -0,0 +1,48 @@ +/** + * Database schemas for local-first storage + * These types define the structure of data stored in lowdb + */ + +export interface RecentProject { + path: string; + name: string; + lastOpenedAt: number; +} + +export interface Tab { + id: string; // nanoid + title: string; + terminalId?: string; + type: "single" | "group"; + createdAt: number; + updatedAt: number; +} + +export interface Workspace { + id: string; // nanoid + path: string | null; // null for new workspaces that haven't opened a project yet + name: string; + order: number; // Explicit order in the workspace tabs (0 = first, 1 = second, etc.) + createdAt: number; + updatedAt: number; + lastOpenedAt: number; +} + +export interface Settings { + lastActiveWorkspaceId?: string; +} + +export interface Database { + workspaces: Workspace[]; + recentProjects: RecentProject[]; + settings: Settings; +} + +/** + * Default database state + */ +export const defaultDatabase: Database = { + workspaces: [], + recentProjects: [], + settings: {}, +}; diff --git a/apps/desktop/src/renderer/react-query/projects/index.ts b/apps/desktop/src/renderer/react-query/projects/index.ts new file mode 100644 index 00000000000..1d552caa22f --- /dev/null +++ b/apps/desktop/src/renderer/react-query/projects/index.ts @@ -0,0 +1,3 @@ +export { useOpenProject } from "./useOpenProject"; +export { useOpenRecent } from "./useOpenRecent"; +export { useRemoveRecent } from "./useRemoveRecent"; diff --git a/apps/desktop/src/renderer/react-query/projects/useOpenProject.ts b/apps/desktop/src/renderer/react-query/projects/useOpenProject.ts new file mode 100644 index 00000000000..67e08ef4689 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/projects/useOpenProject.ts @@ -0,0 +1,22 @@ +import { trpc } from "renderer/lib/trpc"; + +/** + * Mutation hook for opening a new project + * Automatically invalidates workspace queries on success + */ +export function useOpenProject( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.projects.openProject.useMutation({ + ...options, + onSuccess: async (...args) => { + // Auto-invalidate workspace queries + await utils.workspaces.invalidate(); + + // Call user's onSuccess if provided + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/projects/useOpenRecent.ts b/apps/desktop/src/renderer/react-query/projects/useOpenRecent.ts new file mode 100644 index 00000000000..2fdfadfb43e --- /dev/null +++ b/apps/desktop/src/renderer/react-query/projects/useOpenRecent.ts @@ -0,0 +1,22 @@ +import { trpc } from "renderer/lib/trpc"; + +/** + * Mutation hook for opening a recent project + * Automatically invalidates workspace queries on success + */ +export function useOpenRecent( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.projects.openRecent.useMutation({ + ...options, + onSuccess: async (...args) => { + // Auto-invalidate workspace queries + await utils.workspaces.invalidate(); + + // Call user's onSuccess if provided + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/projects/useRemoveRecent.ts b/apps/desktop/src/renderer/react-query/projects/useRemoveRecent.ts new file mode 100644 index 00000000000..052ee20ab54 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/projects/useRemoveRecent.ts @@ -0,0 +1,22 @@ +import { trpc } from "renderer/lib/trpc"; + +/** + * Mutation hook for removing a recent project + * Automatically invalidates recent projects query on success + */ +export function useRemoveRecent( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.projects.removeRecent.useMutation({ + ...options, + onSuccess: async (...args) => { + // Auto-invalidate recent projects query + await utils.projects.getRecents.invalidate(); + + // Call user's onSuccess if provided + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/workspaces/index.ts b/apps/desktop/src/renderer/react-query/workspaces/index.ts new file mode 100644 index 00000000000..e01c29099ee --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/index.ts @@ -0,0 +1,5 @@ +export { useCreateWorkspace } from "./useCreateWorkspace"; +export { useUpdateWorkspace } from "./useUpdateWorkspace"; +export { useDeleteWorkspace } from "./useDeleteWorkspace"; +export { useSetActiveWorkspace } from "./useSetActiveWorkspace"; +export { useReorderWorkspaces } from "./useReorderWorkspaces"; diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts new file mode 100644 index 00000000000..049cac6eed3 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts @@ -0,0 +1,22 @@ +import { trpc } from "renderer/lib/trpc"; + +/** + * Mutation hook for creating a new workspace + * Automatically invalidates all workspace queries on success + */ +export function useCreateWorkspace( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.workspaces.create.useMutation({ + ...options, + onSuccess: async (...args) => { + // Auto-invalidate all workspace queries + await utils.workspaces.invalidate(); + + // Call user's onSuccess if provided + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts new file mode 100644 index 00000000000..bb65e810272 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useDeleteWorkspace.ts @@ -0,0 +1,22 @@ +import { trpc } from "renderer/lib/trpc"; + +/** + * Mutation hook for deleting a workspace + * Automatically invalidates all workspace queries on success + */ +export function useDeleteWorkspace( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.workspaces.delete.useMutation({ + ...options, + onSuccess: async (...args) => { + // Auto-invalidate all workspace queries + await utils.workspaces.invalidate(); + + // Call user's onSuccess if provided + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/workspaces/useReorderWorkspaces.ts b/apps/desktop/src/renderer/react-query/workspaces/useReorderWorkspaces.ts new file mode 100644 index 00000000000..3e9e6e5027b --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useReorderWorkspaces.ts @@ -0,0 +1,22 @@ +import { trpc } from "renderer/lib/trpc"; + +/** + * Mutation hook for reordering workspaces + * Automatically invalidates getAll query on success + */ +export function useReorderWorkspaces( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.workspaces.reorder.useMutation({ + ...options, + onSuccess: async (...args) => { + // Auto-invalidate workspaces list + await utils.workspaces.getAll.invalidate(); + + // Call user's onSuccess if provided + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts new file mode 100644 index 00000000000..7dc43e36b0b --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useSetActiveWorkspace.ts @@ -0,0 +1,23 @@ +import { trpc } from "renderer/lib/trpc"; + +/** + * Mutation hook for setting the active workspace + * Automatically invalidates getActive and getAll queries on success + */ +export function useSetActiveWorkspace( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.workspaces.setActive.useMutation({ + ...options, + onSuccess: async (...args) => { + // Auto-invalidate active workspace and all workspaces queries + await utils.workspaces.getActive.invalidate(); + await utils.workspaces.getAll.invalidate(); + + // Call user's onSuccess if provided + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/workspaces/useUpdateWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useUpdateWorkspace.ts new file mode 100644 index 00000000000..d8e3ce6508a --- /dev/null +++ b/apps/desktop/src/renderer/react-query/workspaces/useUpdateWorkspace.ts @@ -0,0 +1,22 @@ +import { trpc } from "renderer/lib/trpc"; + +/** + * Mutation hook for updating a workspace + * Automatically invalidates all workspace queries on success + */ +export function useUpdateWorkspace( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.workspaces.update.useMutation({ + ...options, + onSuccess: async (...args) => { + // Auto-invalidate all workspace queries + await utils.workspaces.invalidate(); + + // Call user's onSuccess if provided + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/AddWorkspaceButton.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/AddWorkspaceButton.tsx index 64713fbded0..6ac13570941 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/AddWorkspaceButton.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/AddWorkspaceButton.tsx @@ -1,14 +1,20 @@ import { Button } from "@superset/ui/button"; -import { useWorkspacesStore } from "renderer/stores/workspaces"; +import { useCreateWorkspace } from "renderer/react-query/workspaces"; export function AddWorkspaceButton() { - const { addWorkspace } = useWorkspacesStore(); + const createWorkspace = useCreateWorkspace(); + + const handleAddWorkspace = () => { + createWorkspace.mutate({ + name: "New Workspace", + }); + }; return ( - - - +
+ {/* Left column - Start and Recent sections */} +
+
+

+ Welcome to Superset +

+

+ Open a project to get started +

+
+ +
+ + + +
+
+ + {/* Right column - Placeholder for future content */} +
+
+

+ Quick actions and walkthroughs will appear here +

diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentProjectItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentProjectItem.tsx new file mode 100644 index 00000000000..646922d7be5 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentProjectItem.tsx @@ -0,0 +1,70 @@ +import { Folder, X } from "lucide-react"; +import { Button } from "@superset/ui/button"; +import type { RecentProject } from "shared/types"; + +interface RecentProjectItemProps { + project: RecentProject; + onOpen: (path: string) => void; + onRemove: (path: string) => void; +} + +function formatTimestamp(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return days === 1 ? "Yesterday" : `${days} days ago`; + } + if (hours > 0) { + return `${hours} hour${hours > 1 ? "s" : ""} ago`; + } + if (minutes > 0) { + return `${minutes} minute${minutes > 1 ? "s" : ""} ago`; + } + return "Just now"; +} + +export function RecentProjectItem({ + project, + onOpen, + onRemove, +}: RecentProjectItemProps) { + return ( + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentSection.tsx new file mode 100644 index 00000000000..9a0f2e7212e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentSection.tsx @@ -0,0 +1,38 @@ +import { ScrollArea } from "@superset/ui/scroll-area"; + +import { RecentProjectItem } from "./RecentProjectItem"; +import type { RecentProject } from "shared/types"; + +interface RecentSectionProps { + recents: RecentProject[]; + onOpenRecent: (path: string) => void; + onRemoveRecent: (path: string) => void; +} + +export function RecentSection({ + recents, + onOpenRecent, + onRemoveRecent, +}: RecentSectionProps) { + if (recents.length === 0) { + return null; + } + + return ( +
+

Recent

+ +
+ {recents.map((project) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/index.ts new file mode 100644 index 00000000000..7d5fdc9d9e6 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/index.ts @@ -0,0 +1,2 @@ +export { RecentSection } from "./RecentSection"; +export { RecentProjectItem } from "./RecentProjectItem"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/StartSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/StartSection.tsx new file mode 100644 index 00000000000..ac1977c9413 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/StartSection.tsx @@ -0,0 +1,26 @@ +import { Button } from "@superset/ui/button"; +import { FolderOpen } from "lucide-react"; + +interface StartSectionProps { + onOpenProject: () => void; + isLoading?: boolean; +} + +export function StartSection({ onOpenProject, isLoading }: StartSectionProps) { + return ( +
+

Start

+
+ +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/index.ts new file mode 100644 index 00000000000..54f382cc712 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/index.ts @@ -0,0 +1 @@ +export { StartSection } from "./StartSection"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/index.ts new file mode 100644 index 00000000000..5def4cd32df --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/index.ts @@ -0,0 +1,2 @@ +export { StartSection } from "./StartSection"; +export { RecentSection } from "./RecentSection"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx index ce5bfd990e6..c0c32979953 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -1,15 +1,15 @@ -import { useWorkspacesStore } from "renderer/stores/workspaces"; +import { trpc } from "renderer/lib/trpc"; import { ContentView } from "./ContentView"; import { NewWorkspaceView } from "./NewWorkspaceView"; import { Sidebar } from "./Sidebar"; export function WorkspaceView() { - const { workspaces, activeWorkspaceId } = useWorkspacesStore(); - const activeWorkspace = workspaces.find( - (workspace) => workspace.id === activeWorkspaceId, - ); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + + // If no workspace or workspace has no path, show new workspace view + const isNew = !activeWorkspace || activeWorkspace.path === null; - if (activeWorkspace?.isNew) { + if (isNew) { return (
diff --git a/apps/desktop/src/shared/types.ts b/apps/desktop/src/shared/types.ts index 95eef46b1fb..4a4a06e557a 100644 --- a/apps/desktop/src/shared/types.ts +++ b/apps/desktop/src/shared/types.ts @@ -145,3 +145,10 @@ export interface DetectedPort { terminalId: string; detectedAt: string; } + +// Database types for local-first storage +export interface RecentProject { + path: string; + name: string; + lastOpenedAt: number; +} diff --git a/bun.lock b/bun.lock index 75232e4abf3..3370deb80a4 100644 --- a/bun.lock +++ b/bun.lock @@ -98,6 +98,7 @@ "framer-motion": "^12.23.24", "http-proxy": "^1.18.1", "lowdb": "^7.0.1", + "nanoid": "^5.1.6", "node-pty": "1.1.0-beta30", "react": "^19.1.1", "react-arborist": "^3.4.3", @@ -112,6 +113,7 @@ "superjson": "^2.2.5", "tailwind-merge": "^2.6.0", "trpc-electron": "^0.1.2", + "zod": "^4.1.12", "zustand": "^5.0.8", }, "devDependencies": { @@ -2820,7 +2822,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], "napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="], @@ -4186,6 +4188,8 @@ "pkg-conf/find-up": ["find-up@6.3.0", "", { "dependencies": { "locate-path": "^7.1.0", "path-exists": "^5.0.0" } }, "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw=="], + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "postject/commander": ["commander@9.5.0", "", {}, "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="], "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -4814,6 +4818,8 @@ "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "open-editor/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], "open-editor/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],