From 9095eddb123385189e10044b8d722feb5c582976 Mon Sep 17 00:00:00 2001 From: omartelo Date: Wed, 8 Jul 2026 09:11:23 -0300 Subject: [PATCH 1/2] feat: multi-session terminals per project Decouple terminal sessions from projects: a project now owns an ordered list of sessions, each with its own PTY. The UI is reorganized so projects are browser-style tabs on top and sessions live in a resizable left sidebar (replacing the Rail), leaving room for future per-session git info. - lib/sessions.ts: pure session-state reducer (add/close/activate/remove) with per-project active tracking, covered by unit tests - projects provider: sessions map + newSession/closeSession/activateSession; closing the last session recreates an empty one - ProjectTabs (top) and SessionSidebar (left) with a native pointer-drag resize handle (12-30rem, persisted to localStorage) - TerminalView: key PTY by session id; refit via ResizeObserver so the grid and PTY stay in sync on sidebar drag, window resize and zoom - terminal service now keys sessions by opaque id (doc comments; no logic change) - pin pnpm via packageManager field; tests run under bun test --- frontend/package.json | 4 +- frontend/src/App.tsx | 26 ++-- frontend/src/components/ProjectTabs.tsx | 84 +++++++++++ frontend/src/components/Rail.tsx | 110 --------------- frontend/src/components/SessionSidebar.tsx | 156 +++++++++++++++++++++ frontend/src/components/Settings.tsx | 2 +- frontend/src/components/TerminalHost.tsx | 54 +++---- frontend/src/components/TerminalView.tsx | 82 +++++++---- frontend/src/lib/projects.tsx | 59 +++++++- frontend/src/lib/sessions.test.ts | 116 +++++++++++++++ frontend/src/lib/sessions.ts | 119 ++++++++++++++++ frontend/tsconfig.json | 1 + internals/project/project.go | 5 +- internals/terminal/terminal.go | 16 +-- 14 files changed, 649 insertions(+), 185 deletions(-) create mode 100644 frontend/src/components/ProjectTabs.tsx delete mode 100644 frontend/src/components/Rail.tsx create mode 100644 frontend/src/components/SessionSidebar.tsx create mode 100644 frontend/src/lib/sessions.test.ts create mode 100644 frontend/src/lib/sessions.ts diff --git a/frontend/package.json b/frontend/package.json index 854240cea..efe92dbe0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,11 +3,13 @@ "private": true, "version": "0.0.0", "type": "module", + "packageManager": "pnpm@11.3.0", "scripts": { "dev": "vite", "build:dev": "tsc && vite build --minify false --mode development", "build": "tsc && vite build --mode production", - "preview": "vite preview" + "preview": "vite preview", + "test": "bun test" }, "dependencies": { "@base-ui/react": "^1.6.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f87c8eedf..d4ead8556 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,22 +1,26 @@ import { HashRouter, Outlet, Route, Routes } from "react-router-dom" import { SettingsProvider } from "@/lib/settings" import { ProjectsProvider } from "@/lib/projects" -import { Rail } from "@/components/Rail" +import { ProjectTabs } from "@/components/ProjectTabs" +import { SessionSidebar } from "@/components/SessionSidebar" import { TerminalHost } from "@/components/TerminalHost" import { Home } from "@/components/Home" import { Settings } from "@/components/Settings" -// Layout is persistent across navigation: the Rail and the TerminalHost stay -// mounted while the Outlet swaps screens (Home, Settings) on top of the -// terminals. +// Layout is persistent across navigation: the project tabs, session sidebar and +// TerminalHost stay mounted while the Outlet swaps screens (Home, Settings) on +// top of the terminals. function Layout() { return ( -
- -
- - -
+
+ +
+ +
+ + +
+
) } @@ -31,7 +35,7 @@ function App() { } /> {/* Terminals are rendered by TerminalHost; the route only selects which one is visible, so this element is empty. */} - + } /> diff --git a/frontend/src/components/ProjectTabs.tsx b/frontend/src/components/ProjectTabs.tsx new file mode 100644 index 000000000..91ca9636c --- /dev/null +++ b/frontend/src/components/ProjectTabs.tsx @@ -0,0 +1,84 @@ +import { NavLink } from "react-router-dom" +import { Plus, Settings, X } from "lucide-react" +import { cn } from "@/lib/utils" +import { useProjects } from "@/lib/projects" +import type { Project } from "../../bindings/github.com/skipodotdev/skipo/internals/project" + +// ProjectTab is a browser-style tab: the project name, active underline, and a +// close affordance that appears on hover. +function ProjectTab({ + project, + onClose, +}: { + project: Project + onClose: () => void +}) { + return ( + + cn( + "group flex h-8 max-w-52 items-center gap-2 rounded-lg px-3 text-sm text-muted-foreground transition-colors hover:bg-accent/60", + isActive && "bg-accent text-accent-foreground", + ) + } + > + {project.name} + { + event.preventDefault() + onClose() + }} + className="flex size-4 shrink-0 items-center justify-center rounded opacity-0 transition-opacity hover:bg-foreground/15 group-hover:opacity-100" + > + + + + ) +} + +// ProjectTabs is the top strip: open projects as tabs, a button to open another, +// and settings pinned to the right. +export function ProjectTabs() { + const { projects, openProject, closeProject } = useProjects() + + return ( +
+
+ {projects.map((project) => ( + closeProject(project.id)} + /> + ))} + +
+ + + cn( + "flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground", + isActive && "bg-accent text-accent-foreground", + ) + } + > + + +
+ ) +} diff --git a/frontend/src/components/Rail.tsx b/frontend/src/components/Rail.tsx deleted file mode 100644 index 83bb995b7..000000000 --- a/frontend/src/components/Rail.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { NavLink } from "react-router-dom" -import { Plus, Settings, Trash2 } from "lucide-react" -import type { ReactNode } from "react" -import { cn } from "@/lib/utils" -import { useProjects } from "@/lib/projects" -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "@/components/ui/context-menu" - -// RailLink is a Discord-style rail item: a rounded icon tile with a left "pill" -// indicator that grows when the route is active. -function RailLink({ - to, - label, - children, -}: { - to: string - label: string - children: ReactNode -}) { - return ( - - {({ isActive }) => ( - <> - - - {children} - - - )} - - ) -} - -// RailButton is a rail item that fires an action instead of navigating. -function RailButton({ - label, - onClick, - children, -}: { - label: string - onClick: () => void - children: ReactNode -}) { - return ( - - ) -} - -// Rail is the Discord-style vertical navigation: home, the list of open -// projects, an "open project" button, and settings pinned to the bottom. -export function Rail() { - const { projects, openProject, closeProject } = useProjects() - - return ( - - ) -} diff --git a/frontend/src/components/SessionSidebar.tsx b/frontend/src/components/SessionSidebar.tsx new file mode 100644 index 000000000..9a7e9e851 --- /dev/null +++ b/frontend/src/components/SessionSidebar.tsx @@ -0,0 +1,156 @@ +import { useRef, useState } from "react" +import type { PointerEvent as ReactPointerEvent } from "react" +import { useMatch } from "react-router-dom" +import { Plus, X } from "lucide-react" +import { cn } from "@/lib/utils" +import { useProjects } from "@/lib/projects" +import { activeSessionId, sessionsOf, type Session } from "@/lib/sessions" + +// Sidebar width bounds in rem, matching the Tailwind v4 spacing scale. State and +// storage stay in rem; the pointer drag delta arrives in CSS pixels and is +// converted with the 16px root font size Tailwind assumes. +const REM_PX = 16 +const MIN_WIDTH_REM = 12 +const MAX_WIDTH_REM = 30 +const DEFAULT_WIDTH_REM = 15 +const WIDTH_STORAGE_KEY = "skipo.sidebar.width" + +const clampWidth = (rem: number): number => + Math.min(MAX_WIDTH_REM, Math.max(MIN_WIDTH_REM, rem)) + +function readWidth(): number { + const stored = Number(localStorage.getItem(WIDTH_STORAGE_KEY)) + return Number.isFinite(stored) && stored > 0 + ? clampWidth(stored) + : DEFAULT_WIDTH_REM +} + +// SessionRow is one session entry: the label plus a close button on hover. +// +// ponytail: this is where git branch/diff and inline rename will hang off a +// session once those services exist — the row already reserves the room. +function SessionRow({ + session, + active, + onSelect, + onClose, +}: { + session: Session + active: boolean + onSelect: () => void + onClose: () => void +}) { + return ( + + ) +} + +// SessionSidebar lists the active project's sessions and can be drag-resized +// within a fixed pixel range. Width persists across restarts. It renders nothing +// when no project is active (Home, Settings), so it never competes with those +// screens. +// +// Resizing only changes this element's width; the terminal keeps its PTY in sync +// on its own via a ResizeObserver, so the sidebar does not need to know about it. +export function SessionSidebar() { + const { sessions, newSession, closeSession, activateSession } = useProjects() + const match = useMatch("/projects/:projectId") + const projectId = match?.params.projectId + const [width, setWidth] = useState(readWidth) + const dragRef = useRef<{ startX: number; startWidth: number } | null>(null) + + if (!projectId) { + return null + } + + const list = sessionsOf(sessions, projectId) + const activeId = activeSessionId(sessions, projectId) + + const startDrag = (event: ReactPointerEvent) => { + event.preventDefault() + dragRef.current = { startX: event.clientX, startWidth: width } + event.currentTarget.setPointerCapture(event.pointerId) + } + + const onDrag = (event: ReactPointerEvent) => { + const drag = dragRef.current + if (!drag) { + return + } + setWidth(clampWidth(drag.startWidth + (event.clientX - drag.startX) / REM_PX)) + } + + const endDrag = (event: ReactPointerEvent) => { + const drag = dragRef.current + if (!drag) { + return + } + const finalWidth = clampWidth( + drag.startWidth + (event.clientX - drag.startX) / REM_PX, + ) + dragRef.current = null + event.currentTarget.releasePointerCapture(event.pointerId) + setWidth(finalWidth) + localStorage.setItem(WIDTH_STORAGE_KEY, String(finalWidth)) + } + + return ( + + ) +} diff --git a/frontend/src/components/Settings.tsx b/frontend/src/components/Settings.tsx index ab923ffa4..1bba5a67e 100644 --- a/frontend/src/components/Settings.tsx +++ b/frontend/src/components/Settings.tsx @@ -21,7 +21,7 @@ type SectionId = (typeof SECTIONS)[number]["id"] // Settings is a full screen (not a modal): it fills the main area and sits on // top of the persistent terminals, which stay mounted and running behind it. A -// category nav on the left mirrors the terminal Rail; content is on the right. +// category nav sits on the left; content is on the right. export function Settings() { const [active, setActive] = useState("terminal") const [query, setQuery] = useState("") diff --git a/frontend/src/components/TerminalHost.tsx b/frontend/src/components/TerminalHost.tsx index 08cb6284e..b766b8538 100644 --- a/frontend/src/components/TerminalHost.tsx +++ b/frontend/src/components/TerminalHost.tsx @@ -1,35 +1,41 @@ import { useMatch } from "react-router-dom" import { TerminalView } from "@/components/TerminalView" import { useProjects } from "@/lib/projects" +import { activeSessionId, sessionsOf } from "@/lib/sessions" -// TerminalHost keeps one persistent terminal per open project stacked in the -// same area. The router only decides which one is visible — terminals are never -// unmounted by navigation, so background sessions keep running. Inactive layers -// use visibility:hidden (not display:none) so they retain layout size and fit() -// stays correct. +// TerminalHost keeps one persistent terminal per session, across every open +// project, stacked in the same area. The router picks the active project and the +// per-project active session decides which layer is visible — terminals are +// never unmounted by navigation, so background sessions keep running. Inactive +// layers use visibility:hidden (not display:none) so they retain layout size and +// fit() stays correct. export function TerminalHost() { - const { projects } = useProjects() - const match = useMatch("/projects/:id") - const activeId = match?.params.id ?? null + const { projects, sessions } = useProjects() + const match = useMatch("/projects/:projectId") + const activeProjectId = match?.params.projectId ?? null return ( <> - {projects.map((project) => { - const visible = project.id === activeId - return ( -
- -
- ) + {projects.flatMap((project) => { + const projectActiveId = activeSessionId(sessions, project.id) + return sessionsOf(sessions, project.id).map((session) => { + const visible = + project.id === activeProjectId && session.id === projectActiveId + return ( +
+ +
+ ) + }) })} ) diff --git a/frontend/src/components/TerminalView.tsx b/frontend/src/components/TerminalView.tsx index f4a760448..e8ad36531 100644 --- a/frontend/src/components/TerminalView.tsx +++ b/frontend/src/components/TerminalView.tsx @@ -6,7 +6,7 @@ import { useSettings } from "@/lib/settings" import type { ResolvedTheme } from "@/lib/settings" // Event name prefixes mirror the backend (internals/terminal); the concrete -// event carries the project ID as a suffix. +// event carries the session ID as a suffix. const DATA_EVENT_PREFIX = "terminal:data:" const EXIT_EVENT_PREFIX = "terminal:exit:" @@ -52,12 +52,12 @@ function decodeBase64(data: string): Uint8Array { } interface TerminalViewProps { - projectId: string + sessionId: string cwd: string visible: boolean } -export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) { +export function TerminalView({ sessionId, cwd, visible }: TerminalViewProps) { const { font, resolvedTerminalTheme } = useSettings() const containerRef = useRef(null) const termRef = useRef(null) @@ -70,9 +70,9 @@ export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) { fontRef.current = font themeRef.current = resolvedTerminalTheme - // Create the terminal and its PTY session once per project. The session runs - // in the background regardless of visibility and is only torn down when the - // project is closed (this component unmounts) — never on navigation. + // Create the terminal and its PTY session once per session id. The session + // runs in the background regardless of visibility and is only torn down when + // it is closed (this component unmounts) — never on navigation. useEffect(() => { const container = containerRef.current if (!container) { @@ -82,12 +82,40 @@ export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) { let disposed = false const cleanups: Array<() => void> = [] - const onWindowResize = () => { - const term = termRef.current - fitRef.current?.fit() - if (term && visibleRef.current) { - void Service.Resize(projectId, term.cols, term.rows) + // Refit the grid whenever the container changes size — window resize, sidebar + // drag, zoom. Guard on integer dimensions: fit() nudges the canvas, which can + // make the observer re-fire at sub-pixel deltas and loop into a flicker. + // Ignoring sub-1px changes breaks that loop. Coalesced to one refit per frame. + // Only the visible terminal pushes its new size to the PTY; hidden ones refit + // but sync their PTY when shown. + let lastWidth = 0 + let lastHeight = 0 + let refitFrame = 0 + const scheduleRefit = (entries: ResizeObserverEntry[]) => { + const rect = entries[0]?.contentRect + if (rect) { + const width = Math.round(rect.width) + const height = Math.round(rect.height) + if (width === lastWidth && height === lastHeight) { + return + } + lastWidth = width + lastHeight = height } + if (refitFrame) { + return + } + refitFrame = requestAnimationFrame(() => { + refitFrame = 0 + const term = termRef.current + if (!term) { + return + } + fitRef.current?.fit() + if (visibleRef.current) { + void Service.Resize(sessionId, term.cols, term.rows) + } + }) } void (async () => { @@ -111,26 +139,32 @@ export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) { termRef.current = term fitRef.current = fit - const dataInput = term.onData((data) => Service.Write(projectId, data)) + const dataInput = term.onData((data) => Service.Write(sessionId, data)) const resizeInput = term.onResize(({ cols, rows }) => { if (visibleRef.current) { - void Service.Resize(projectId, cols, rows) + void Service.Resize(sessionId, cols, rows) } }) cleanups.push(() => dataInput.dispose(), () => resizeInput.dispose()) - const offData = Events.On(DATA_EVENT_PREFIX + projectId, (event) => { + const offData = Events.On(DATA_EVENT_PREFIX + sessionId, (event) => { term.write(decodeBase64(event.data as string)) }) - const offExit = Events.On(EXIT_EVENT_PREFIX + projectId, () => { + const offExit = Events.On(EXIT_EVENT_PREFIX + sessionId, () => { term.write("\r\n[process exited]\r\n") }) cleanups.push(offData, offExit) - window.addEventListener("resize", onWindowResize) - cleanups.push(() => window.removeEventListener("resize", onWindowResize)) + const resizeObserver = new ResizeObserver(scheduleRefit) + resizeObserver.observe(container) + cleanups.push(() => { + if (refitFrame) { + cancelAnimationFrame(refitFrame) + } + resizeObserver.disconnect() + }) - await Service.Start(projectId, cwd, term.cols, term.rows) + await Service.Start(sessionId, cwd, term.cols, term.rows) if (visibleRef.current) { term.focus() } @@ -141,12 +175,12 @@ export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) { for (const cleanup of cleanups) { cleanup() } - void Service.Close(projectId) + void Service.Close(sessionId) termRef.current?.dispose() termRef.current = null fitRef.current = null } - }, [projectId, cwd]) + }, [sessionId, cwd]) // Apply a font change live to the running terminal. useEffect(() => { @@ -160,10 +194,10 @@ export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) { // Font metrics changed: recompute the grid and sync the visible PTY. fitRef.current?.fit() if (visibleRef.current) { - void Service.Resize(projectId, term.cols, term.rows) + void Service.Resize(sessionId, term.cols, term.rows) } })() - }, [font, projectId]) + }, [font, sessionId]) // Apply a terminal theme change live to the running terminal. useEffect(() => { @@ -181,9 +215,9 @@ export function TerminalView({ projectId, cwd, visible }: TerminalViewProps) { return } fitRef.current?.fit() - void Service.Resize(projectId, term.cols, term.rows) + void Service.Resize(sessionId, term.cols, term.rows) term.focus() - }, [visible, projectId]) + }, [visible, sessionId]) return
} diff --git a/frontend/src/lib/projects.tsx b/frontend/src/lib/projects.tsx index f8c722c6c..1593ea48b 100644 --- a/frontend/src/lib/projects.tsx +++ b/frontend/src/lib/projects.tsx @@ -5,21 +5,40 @@ import { Project, Service as ProjectService, } from "../../bindings/github.com/skipodotdev/skipo/internals/project" +import { + addSession, + closeSession as removeSession, + removeProject, + sessionsOf, + setActiveSession, + type SessionState, +} from "./sessions" interface ProjectsValue { projects: Project[] + /** Sessions keyed by project id, with the active session per project. */ + sessions: SessionState /** Show the OS directory picker, add the chosen project and navigate to it. */ openProject: () => Promise - /** Remove a project (unmounts its terminal, which closes the PTY). */ + /** Remove a project (unmounts its terminals, which closes their PTYs). */ closeProject: (id: string) => void + /** Open a new terminal session in a project and focus it. */ + newSession: (projectId: string) => void + /** Close a session; closing the last one recreates an empty one. */ + closeSession: (projectId: string, sessionId: string) => void + /** Focus an existing session within a project. */ + activateSession: (projectId: string, sessionId: string) => void } const ProjectsContext = createContext(null) -// ponytail: open projects are kept in memory only. Persist to a config file -// (and restore on launch) when projects should survive restarts. +const newSessionId = (): string => crypto.randomUUID() + +// ponytail: open projects and their sessions are kept in memory only. Persist to +// a config file (and restore on launch) when they should survive restarts. export function ProjectsProvider({ children }: { children: ReactNode }) { const [projects, setProjects] = useState([]) + const [sessions, setSessions] = useState({}) const navigate = useNavigate() const openProject = useCallback(async () => { @@ -30,19 +49,51 @@ export function ProjectsProvider({ children }: { children: ReactNode }) { setProjects((prev) => prev.some((project) => project.id === picked.id) ? prev : [...prev, picked], ) + setSessions((prev) => + prev[picked.id] ? prev : addSession(prev, picked.id, newSessionId()), + ) navigate(`/projects/${picked.id}`) }, [navigate]) const closeProject = useCallback( (id: string) => { setProjects((prev) => prev.filter((project) => project.id !== id)) + setSessions((prev) => removeProject(prev, id)) navigate("/") }, [navigate], ) + const newSession = useCallback((projectId: string) => { + setSessions((prev) => addSession(prev, projectId, newSessionId())) + }, []) + + const closeSession = useCallback((projectId: string, sessionId: string) => { + setSessions((prev) => { + const next = removeSession(prev, projectId, sessionId) + // A project always keeps at least one session; recreate when emptied. + return sessionsOf(next, projectId).length === 0 + ? addSession(next, projectId, newSessionId()) + : next + }) + }, []) + + const activateSession = useCallback((projectId: string, sessionId: string) => { + setSessions((prev) => setActiveSession(prev, projectId, sessionId)) + }, []) + return ( - + {children} ) diff --git a/frontend/src/lib/sessions.test.ts b/frontend/src/lib/sessions.test.ts new file mode 100644 index 000000000..960743ccc --- /dev/null +++ b/frontend/src/lib/sessions.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "bun:test" +import { + activeSessionId, + addSession, + closeSession, + createProjectSessions, + removeProject, + sessionsOf, + setActiveSession, + type SessionState, +} from "./sessions" + +const P = "project-1" + +// buildState creates a project with `n` sessions (ids "s1".."sn"), the last one +// active. +function buildState(n: number): SessionState { + let state: SessionState = { [P]: createProjectSessions("s1") } + for (let i = 2; i <= n; i++) { + state = addSession(state, P, `s${i}`) + } + return state +} + +describe("createProjectSessions", () => { + it("seeds one active session labeled Session 1", () => { + const project = createProjectSessions("s1") + expect(project.sessions).toEqual([{ id: "s1", label: "Session 1" }]) + expect(project.activeId).toBe("s1") + expect(project.nextSeq).toBe(2) + }) +}) + +describe("addSession", () => { + it("creates the project entry when absent", () => { + const state = addSession({}, P, "s1") + expect(sessionsOf(state, P)).toHaveLength(1) + expect(activeSessionId(state, P)).toBe("s1") + }) + + it("appends, focuses the new session and advances the label sequence", () => { + const state = addSession(buildState(1), P, "s2") + expect(sessionsOf(state, P).map((s) => s.label)).toEqual([ + "Session 1", + "Session 2", + ]) + expect(activeSessionId(state, P)).toBe("s2") + }) + + it("does not mutate the input state", () => { + const before = buildState(1) + addSession(before, P, "s2") + expect(sessionsOf(before, P)).toHaveLength(1) + }) +}) + +describe("closeSession", () => { + it("removes a non-active session and keeps the active one", () => { + const state = closeSession(buildState(3), P, "s1") // active is s3 + expect(sessionsOf(state, P).map((s) => s.id)).toEqual(["s2", "s3"]) + expect(activeSessionId(state, P)).toBe("s3") + }) + + it("moves focus to the slot filler when the active session closes", () => { + // s1, s2, s3 with s2 active → closing s2 focuses s3 (fills index 1). + let state = buildState(3) + state = setActiveSession(state, P, "s2") + state = closeSession(state, P, "s2") + expect(activeSessionId(state, P)).toBe("s3") + }) + + it("falls back to the previous session when the last one closes", () => { + const state = closeSession(buildState(3), P, "s3") // active is s3 (last) + expect(activeSessionId(state, P)).toBe("s2") + }) + + it("empties the project but preserves nextSeq for recreate", () => { + const state = closeSession(buildState(1), P, "s1") + expect(sessionsOf(state, P)).toHaveLength(0) + expect(activeSessionId(state, P)).toBe("") + // A recreate keeps counting up instead of reusing "Session 1". + const recreated = addSession(state, P, "s2") + expect(sessionsOf(recreated, P)[0].label).toBe("Session 2") + }) + + it("ignores unknown project or session ids", () => { + const state = buildState(2) + expect(closeSession(state, "nope", "s1")).toBe(state) + expect(closeSession(state, P, "ghost")).toBe(state) + }) +}) + +describe("setActiveSession", () => { + it("focuses an existing session", () => { + const state = setActiveSession(buildState(3), P, "s1") + expect(activeSessionId(state, P)).toBe("s1") + }) + + it("ignores unknown ids", () => { + const state = buildState(2) + expect(setActiveSession(state, P, "ghost")).toBe(state) + }) +}) + +describe("removeProject", () => { + it("drops the project and its sessions", () => { + const state = removeProject(buildState(2), P) + expect(sessionsOf(state, P)).toHaveLength(0) + expect(P in state).toBe(false) + }) + + it("is a no-op for an unknown project", () => { + const state = buildState(1) + expect(removeProject(state, "nope")).toBe(state) + }) +}) diff --git a/frontend/src/lib/sessions.ts b/frontend/src/lib/sessions.ts new file mode 100644 index 000000000..b0d4ffcfa --- /dev/null +++ b/frontend/src/lib/sessions.ts @@ -0,0 +1,119 @@ +// Session state model for multi-session-per-project. A project owns an ordered +// list of terminal sessions and tracks which one is active. Sessions decouple +// from the project: each has its own opaque id used as the backend PTY key, so +// the terminal service needs no knowledge of projects at all. +// +// Every function here is pure — it returns a new state and never mutates the +// input — which keeps the reducer logic testable without React or a PTY. + +export interface Session { + id: string + label: string +} + +export interface ProjectSessions { + sessions: Session[] + activeId: string + // Monotonic per-project counter for default labels. It only grows, so closing + // a session never renumbers the survivors. + nextSeq: number +} + +export type SessionState = Record + +// createProjectSessions seeds a project with its first session. +export function createProjectSessions(firstSessionId: string): ProjectSessions { + return { + sessions: [{ id: firstSessionId, label: "Session 1" }], + activeId: firstSessionId, + nextSeq: 2, + } +} + +// addSession appends a session to a project and makes it active. If the project +// has no entry yet, it is created with this session as the first. +export function addSession( + state: SessionState, + projectId: string, + sessionId: string, +): SessionState { + const current = state[projectId] + if (!current) { + return { ...state, [projectId]: createProjectSessions(sessionId) } + } + const session: Session = { id: sessionId, label: `Session ${current.nextSeq}` } + return { + ...state, + [projectId]: { + sessions: [...current.sessions, session], + activeId: sessionId, + nextSeq: current.nextSeq + 1, + }, + } +} + +// closeSession removes a session. When the active one is closed, focus moves to +// a neighbor. The project entry is kept even when empty so nextSeq (and the +// caller's recreate-on-empty policy) survive. +export function closeSession( + state: SessionState, + projectId: string, + sessionId: string, +): SessionState { + const current = state[projectId] + if (!current) { + return state + } + const index = current.sessions.findIndex((s) => s.id === sessionId) + if (index === -1) { + return state + } + const sessions = current.sessions.filter((s) => s.id !== sessionId) + const activeId = + current.activeId === sessionId ? neighborId(sessions, index) : current.activeId + return { ...state, [projectId]: { ...current, sessions, activeId } } +} + +// neighborId picks the session that fills the closed slot, falling back to the +// previous one, or "" when the list is now empty. +function neighborId(sessions: Session[], removedIndex: number): string { + if (sessions.length === 0) { + return "" + } + const next = sessions[removedIndex] ?? sessions[removedIndex - 1] + return next.id +} + +// setActiveSession focuses an existing session; unknown ids are ignored. +export function setActiveSession( + state: SessionState, + projectId: string, + sessionId: string, +): SessionState { + const current = state[projectId] + if (!current || !current.sessions.some((s) => s.id === sessionId)) { + return state + } + return { ...state, [projectId]: { ...current, activeId: sessionId } } +} + +// removeProject drops a project and all its sessions. +export function removeProject( + state: SessionState, + projectId: string, +): SessionState { + if (!(projectId in state)) { + return state + } + const next = { ...state } + delete next[projectId] + return next +} + +export function sessionsOf(state: SessionState, projectId: string): Session[] { + return state[projectId]?.sessions ?? [] +} + +export function activeSessionId(state: SessionState, projectId: string): string { + return state[projectId]?.activeId ?? "" +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index fc3e4e3a0..5709ce0b1 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -28,4 +28,5 @@ } }, "include": ["src", "bindings"], + "exclude": ["src/**/*.test.ts"], } diff --git a/internals/project/project.go b/internals/project/project.go index b35577d1c..e6a97c03d 100644 --- a/internals/project/project.go +++ b/internals/project/project.go @@ -1,6 +1,7 @@ // Package project opens project directories through the OS file picker. Project -// metadata (list, active) lives in the frontend; this service only turns a -// picked directory into a stable identity the terminal sessions can key on. +// metadata (list, active, sessions) lives in the frontend; this service only +// turns a picked directory into a stable identity the frontend can group its +// terminal sessions under. package project import ( diff --git a/internals/terminal/terminal.go b/internals/terminal/terminal.go index 23815c07f..5f3e54be2 100644 --- a/internals/terminal/terminal.go +++ b/internals/terminal/terminal.go @@ -1,7 +1,7 @@ // Package terminal spawns PTY-backed shell sessions and bridges their I/O to the -// frontend over Wails events. Sessions are keyed by project ID and run +// frontend over Wails events. Sessions are keyed by an opaque session ID and run // independently of the frontend, so navigating away from a project (or hiding -// its terminal) never kills its shell. +// its terminal) never kills its shell. A project may own several sessions. package terminal import ( @@ -33,7 +33,7 @@ type session struct { cmd *exec.Cmd } -// Service manages PTY-backed shell sessions keyed by project ID. +// Service manages PTY-backed shell sessions keyed by session ID. type Service struct { mu sync.Mutex sessions map[string]*session @@ -53,9 +53,9 @@ func defaultShell() string { return "/bin/sh" } -// Start spawns the user's shell for project id, attached to a new PTY sized to +// Start spawns the user's shell for session id, attached to a new PTY sized to // cols x rows and rooted at cwd, then streams its output to the frontend. An -// empty cwd defaults to the user's home directory. Starting a project that is +// empty cwd defaults to the user's home directory. Starting a session that is // already running is a no-op. func (s *Service) Start(id, cwd string, cols, rows int) error { s.mu.Lock() @@ -112,7 +112,7 @@ func (s *Service) stream(id string, ptmx *os.File, cmd *exec.Cmd) { application.Get().Event.Emit(exitEventPrefix + id) } -// Write forwards keyboard input from the frontend to a project's PTY. +// Write forwards keyboard input from the frontend to a session's PTY. func (s *Service) Write(id, data string) error { ptmx := s.ptmxOf(id) if ptmx == nil { @@ -122,7 +122,7 @@ func (s *Service) Write(id, data string) error { return err } -// Resize updates a project's PTY window size. The frontend only calls this for +// Resize updates a session's PTY window size. The frontend only calls this for // the visible terminal; a hidden terminal is resized on the next time it is // shown. func (s *Service) Resize(id string, cols, rows int) error { @@ -133,7 +133,7 @@ func (s *Service) Resize(id string, cols, rows int) error { return pty.Setsize(ptmx, &pty.Winsize{Rows: uint16(rows), Cols: uint16(cols)}) } -// Close terminates a project's shell session, if any. +// Close terminates a session's shell, if any. func (s *Service) Close(id string) error { s.mu.Lock() sess, ok := s.sessions[id] From 8bac101941f347879949f317c3f7815351033940 Mon Sep 17 00:00:00 2001 From: omartelo Date: Wed, 8 Jul 2026 09:28:56 -0300 Subject: [PATCH 2/2] test: run session tests with vitest Replace the bun test runner (a stopgap from when npm installs were failing on this pnpm project) with vitest, the intended runner for the stack. Pure session logic, so a standalone vitest.config.ts with a node environment is enough; vite.config.ts is avoided because its Wails plugin needs the desktop bridge absent under the runner. --- frontend/package.json | 5 +- frontend/pnpm-lock.yaml | 247 ++++++++++++++++++++++++++++++ frontend/src/lib/sessions.test.ts | 2 +- frontend/vitest.config.ts | 17 ++ 4 files changed, 268 insertions(+), 3 deletions(-) create mode 100644 frontend/vitest.config.ts diff --git a/frontend/package.json b/frontend/package.json index efe92dbe0..e92fcd403 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,7 @@ "build:dev": "tsc && vite build --minify false --mode development", "build": "tsc && vite build --mode production", "preview": "vite preview", - "test": "bun test" + "test": "vitest run" }, "dependencies": { "@base-ui/react": "^1.6.0", @@ -34,6 +34,7 @@ "@vitejs/plugin-react": "^6.0.0", "tailwindcss": "^4.3.2", "typescript": "^5.2.2", - "vite": "^8.0.5" + "vite": "^8.0.5", + "vitest": "^4.1.10" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 0eada4c53..c870e2307 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: vite: specifier: ^8.0.5 version: 8.1.3(@types/node@26.1.0)(jiti@2.7.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)) packages: @@ -427,6 +430,9 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tailwindcss/node@4.3.2': resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==} @@ -527,6 +533,15 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/node@26.1.0': resolution: {integrity: sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==} @@ -557,6 +572,35 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@wailsio/runtime@3.0.0-alpha.97': resolution: {integrity: sha512-alvEG6B0YqW7AcBcNaUWNMD9q8JWqx5AO6BPOZpijBeq4ZjhQxSom3jvvq8Z1mWSMKPEjSvlFirKIh9NLI3ajA==} @@ -598,6 +642,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -655,6 +703,10 @@ packages: caniuse-lite@1.0.30001802: resolution: {integrity: sha512-vmv8ub2xwTNmljSKf82mtCk5JH7hC+YgzLj3P5zotvA0tPQ9016tdNNOG8WRca1IxOnhSsivB+J0z5FeE5LOUw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -842,6 +894,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.0: + resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -858,6 +913,9 @@ packages: engines: {node: '>=4'} hasBin: true + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -878,6 +936,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} engines: {node: '>= 16'} @@ -1359,6 +1421,10 @@ packages: resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} engines: {node: '>= 10'} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -1432,6 +1498,9 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1613,6 +1682,9 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -1631,10 +1703,16 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -1686,10 +1764,21 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -1804,6 +1893,47 @@ packages: yaml: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1814,6 +1944,11 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -2230,6 +2365,8 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@tailwindcss/node@4.3.2': dependencies: '@jridgewell/remapping': 2.3.5 @@ -2309,6 +2446,15 @@ snapshots: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/node@26.1.0': dependencies: undici-types: 8.3.0 @@ -2331,6 +2477,47 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.1.3(@types/node@26.1.0)(jiti@2.7.0) + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.3(@types/node@26.1.0)(jiti@2.7.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@wailsio/runtime@3.0.0-alpha.97': {} accepts@2.0.0: @@ -2361,6 +2548,8 @@ snapshots: argparse@2.0.1: {} + assertion-error@2.0.1: {} + ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -2421,6 +2610,8 @@ snapshots: caniuse-lite@1.0.30001802: {} + chai@6.2.2: {} + chalk@5.6.2: {} class-variance-authority@0.7.1: @@ -2561,6 +2752,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.3.0: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -2571,6 +2764,10 @@ snapshots: esprima@4.0.1: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + etag@1.8.1: {} eventsource-parser@3.1.0: {} @@ -2606,6 +2803,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expect-type@1.4.0: {} + express-rate-limit@8.5.2(express@5.2.1): dependencies: express: 5.2.1 @@ -2999,6 +3198,8 @@ snapshots: object-treeify@1.1.33: {} + obug@2.1.3: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -3077,6 +3278,8 @@ snapshots: path-to-regexp@8.4.2: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -3327,6 +3530,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -3337,8 +3542,12 @@ snapshots: source-map@0.6.1: {} + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.1.0: {} + stdin-discarder@0.2.2: {} string-width@7.2.0: @@ -3377,11 +3586,17 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -3449,6 +3664,33 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 + vitest@4.1.10(@types/node@26.1.0)(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.3(@types/node@26.1.0)(jiti@2.7.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.3(@types/node@26.1.0)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.0 + transitivePeerDependencies: + - msw + which@2.0.2: dependencies: isexe: 2.0.0 @@ -3457,6 +3699,11 @@ snapshots: dependencies: isexe: 3.1.5 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrappy@1.0.2: {} wsl-utils@0.3.1: diff --git a/frontend/src/lib/sessions.test.ts b/frontend/src/lib/sessions.test.ts index 960743ccc..e0e9acbb5 100644 --- a/frontend/src/lib/sessions.test.ts +++ b/frontend/src/lib/sessions.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "bun:test" +import { describe, expect, it } from "vitest" import { activeSessionId, addSession, diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 000000000..c63ce1eaa --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,17 @@ +import path from "node:path" +import { defineConfig } from "vitest/config" + +// Standalone from vite.config.ts: the Wails runtime plugin needs the desktop +// bridge, which is absent under the test runner. Session logic is pure, so a +// plain node environment is enough. +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +})