diff --git a/package.json b/package.json index 530ab937c22..ae95de25dd0 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "prepare": "husky", "random": "echo 'Random script'", "hello": "echo 'Hello World!'", - "test": "echo 'do not run tests from root' && exit 1" + "test": "echo 'do not run tests from root' && exit 1", + "pr:create": "bun ./script/pr-create.ts" }, "workspaces": { "packages": [ diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts index 6ad64f59278..09fa18f6ec2 100644 --- a/packages/app/e2e/projects/projects-switch.spec.ts +++ b/packages/app/e2e/projects/projects-switch.spec.ts @@ -2,7 +2,7 @@ import { base64Decode } from "@opencode-ai/util/encode" import type { Page } from "@playwright/test" import { test, expect } from "../fixtures" import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions" -import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" +import { projectSwitchSelector, promptSelector } from "../selectors" import { dirSlug, resolveDirectory } from "../utils" async function workspaces(page: Page, directory: string, enabled: boolean) { @@ -90,19 +90,8 @@ test("switching back to a project opens the latest workspace session", async ({ const space = await resolveDirectory(dir) const next = dirSlug(space) trackDirectory(space) - await openSidebar(page) - - const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first() - await expect(item).toBeVisible() - await item.hover() - - const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first() - await expect(btn).toBeVisible() - await btn.click({ force: true }) - - // A new workspace can be discovered via a transient slug before the route and sidebar - // settle to the canonical workspace path on Windows, so interact with either and assert - // against the resolved workspace slug. + // A new workspace can be discovered via a transient slug before the route settles to + // the canonical workspace path on Windows. await waitSlug(page) await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`)) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 86f3321e464..7d4bd9e887f 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -3,7 +3,7 @@ import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage" import type { Accessor } from "solid-js" type PickerPaths = string | string[] | null -type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean } +type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean; defaultPath?: string } type OpenFilePickerOptions = { title?: string; multiple?: boolean } type SaveFilePickerOptions = { title?: string; defaultPath?: string } type UpdateInfo = { updateAvailable: boolean; version?: string } diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c278d6270e5..7d20ed0fb97 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -610,6 +610,9 @@ export const dict = { "sidebar.project.recentSessions": "Recent sessions", "sidebar.project.viewAllSessions": "View all sessions", "sidebar.project.clearNotifications": "Clear notifications", + "sidebar.project.addSubProject": "Add sub-project", + "sidebar.project.removeSubProject": "Remove from sub-project group", + "sidebar.group.allProjects": "All projects", "app.name.desktop": "OpenCode Desktop", diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 9e231e2d285..2a443ffa50d 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -1,5 +1,20 @@ @import "@opencode-ai/ui/styles/tailwind"; +@keyframes sidebar-subproject-enter { + from { + opacity: 0; + transform: translateY(-6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.sidebar-subproject-enter { + animation: sidebar-subproject-enter 180ms ease-out; +} + @layer components { [data-component="getting-started"] { container-type: inline-size; diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 9c359aafbda..9033397c72f 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -89,6 +89,8 @@ export default function Layout(props: ParentProps) { lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } }, activeProject: undefined as string | undefined, activeWorkspace: undefined as string | undefined, + expandedProject: undefined as string | undefined, + projectParent: {} as Record, workspaceOrder: {} as Record, workspaceName: {} as Record, workspaceBranchName: {} as Record>, @@ -235,6 +237,51 @@ export default function Layout(props: ParentProps) { return layout.projects.list().find((project) => project.worktree === id) }) + const projectParent = createMemo(() => { + const projects = layout.projects.list().map((project) => workspaceKey(project.worktree)) + const set = new Set(projects) + return Object.entries(store.projectParent).reduce( + (acc, [child, parent]) => { + const key = workspaceKey(child) + const root = workspaceKey(parent) + if (!set.has(key) || !set.has(root) || key === root) return acc + acc[key] = root + return acc + }, + {} as Record, + ) + }) + + const projectParentSet = createMemo(() => new Set(Object.values(projectParent()))) + const subProjectSet = createMemo(() => new Set(Object.keys(projectParent()))) + + const subProjectsByParent = createMemo(() => { + const map = new Map() + for (const project of layout.projects.list()) { + const key = workspaceKey(project.worktree) + const parent = projectParent()[key] + if (!parent) continue + const list = map.get(parent) + if (list) { + list.push(project) + continue + } + map.set(parent, [project]) + } + return map + }) + + const groupedProjects = createMemo(() => { + const expanded = store.expandedProject ? workspaceKey(store.expandedProject) : undefined + return layout.projects.list().flatMap((project) => { + const key = workspaceKey(project.worktree) + if (subProjectSet().has(key)) return [] + const children = subProjectsByParent().get(key) ?? [] + if (expanded !== key) return [project] + return [project, ...children] + }) + }) + createEffect(() => { const p = hoverProjectData() if (p) { @@ -261,6 +308,38 @@ export default function Layout(props: ParentProps) { setHoverProject(undefined) }) + createEffect(() => { + const projects = new Set(layout.projects.list().map((project) => workspaceKey(project.worktree))) + const next = Object.entries(store.projectParent).reduce( + (acc, [child, parent]) => { + const key = workspaceKey(child) + const root = workspaceKey(parent) + if (!projects.has(key) || !projects.has(root) || key === root) return acc + acc[key] = root + return acc + }, + {} as Record, + ) + + const current = Object.entries(store.projectParent).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + const cleaned = Object.entries(next).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + if (JSON.stringify(current) === JSON.stringify(cleaned)) return + setStore("projectParent", next) + }) + + createEffect(() => { + const expanded = store.expandedProject + if (!expanded) return + const key = workspaceKey(expanded) + if (!projectParentSet().has(key)) { + setStore("expandedProject", undefined) + return + } + const visible = layout.projects.list().some((project) => workspaceKey(project.worktree) === key) + if (visible) return + setStore("expandedProject", undefined) + }) + const autoselecting = createMemo(() => { if (params.dir) return false if (!state.autoselect) return false @@ -1161,6 +1240,10 @@ export default function Layout(props: ParentProps) { async function navigateToProject(directory: string | undefined) { if (!directory) return const root = projectRoot(directory) + const key = workspaceKey(root) + const parent = projectParent()[key] + const expanded = parent ?? (projectParentSet().has(key) ? key : undefined) + setStore("expandedProject", expanded) server.projects.touch(root) const project = layout.projects.list().find((item) => item.worktree === root) let dirs = project @@ -1330,30 +1413,71 @@ export default function Layout(props: ParentProps) { const showEditProjectDialog = (project: LocalProject) => dialog.show(() => ) - async function chooseProject() { - function resolve(result: string | string[] | null) { - if (Array.isArray(result)) { - for (const directory of result) { - openProject(directory, false) - } - navigateToProject(result[0]) - } else if (result) { - openProject(result) - } + async function pickProjects(title: string, onSelect: (result: string[]) => void, opts?: { defaultPath?: string }) { + const resolve = (result: string | string[] | null) => { + if (!result) return + const list = Array.isArray(result) ? result : [result] + if (list.length === 0) return + onSelect(list) } if (platform.openDirectoryPickerDialog && server.isLocal()) { const result = await platform.openDirectoryPickerDialog?.({ - title: language.t("command.project.open"), + title, multiple: true, + defaultPath: opts?.defaultPath, }) resolve(result) - } else { - dialog.show( - () => , - () => resolve(null), - ) + return } + + dialog.show( + () => , + () => resolve(null), + ) + } + + async function chooseProject() { + await pickProjects(language.t("command.project.open"), (result) => { + for (const directory of result) { + openProject(directory, false) + } + navigateToProject(result[0]) + }) + } + + async function addSubProject(parent: LocalProject) { + const root = workspaceKey(parent.worktree) + await pickProjects( + language.t("sidebar.project.addSubProject"), + (result) => { + for (const directory of result) { + const key = workspaceKey(directory) + if (key === root) continue + layout.projects.open(directory) + setStore("projectParent", key, root) + } + setStore("expandedProject", root) + navigateToProject(result[0]) + }, + { defaultPath: parent.worktree }, + ) + } + + function removeSubProject(project: LocalProject) { + const key = workspaceKey(project.worktree) + const parent = projectParent()[key] + if (!parent) return + const siblings = Object.entries(projectParent()).some( + ([child, value]) => workspaceKey(child) !== key && value === parent, + ) + setStore( + "projectParent", + produce((draft) => { + delete draft[key] + }), + ) + if (!siblings) setStore("expandedProject", undefined) } const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => { @@ -1859,6 +1983,9 @@ export default function Layout(props: ParentProps) { navigateToProject, openSidebar: () => layout.sidebar.open(), closeProject, + addSubProject, + removeSubProject, + hasParentProject: (project) => !!projectParent()[workspaceKey(project.worktree)], showEditProjectDialog, toggleProjectWorkspaces, workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(), @@ -2140,7 +2267,8 @@ export default function Layout(props: ParentProps) { layout.sidebar.opened()} aimMove={aim.move} - projects={() => layout.projects.list()} + projects={groupedProjects} + isSubProject={(project) => subProjectSet().has(workspaceKey(project.worktree))} renderProject={(project) => ( )} @@ -2215,7 +2343,8 @@ export default function Layout(props: ParentProps) { mobile opened={() => layout.sidebar.opened()} aimMove={aim.move} - projects={() => layout.projects.list()} + projects={groupedProjects} + isSubProject={(project) => subProjectSet().has(workspaceKey(project.worktree))} renderProject={(project) => ( )} diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index d1569dbd9a6..b9b11f31601 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -6,9 +6,19 @@ import { parseDeepLink, parseNewSessionDeepLink, } from "./deep-links" -import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers" import { type Session } from "@opencode-ai/sdk/v2/client" -import { hasProjectPermissions, latestRootSession } from "./helpers" +import { + createProjectGroups, + displayName, + errorMessage, + getDraggableId, + hasProjectPermissions, + latestRootSession, + projectGroupID, + projectGroupLabel, + syncWorkspaceOrder, + workspaceKey, +} from "./helpers" const session = (input: Partial & Pick) => ({ @@ -208,4 +218,44 @@ describe("layout workspace helpers", () => { expect(errorMessage(new Error("broken"), "fallback")).toBe("broken") expect(errorMessage("unknown", "fallback")).toBe("fallback") }) + + test("builds a stable project group id", () => { + expect(projectGroupID("/work/company/repo-a")).toBe("/work/company") + expect(projectGroupID("C:\\work\\company\\repo-a")).toBe("C:/work/company") + }) + + test("formats project group labels", () => { + expect(projectGroupLabel("/work/company")).toBe("company") + expect(projectGroupLabel("/")).toBe("/") + expect(projectGroupLabel("C:/")).toBe("C:") + }) + + test("creates an all-projects group and parent groups", () => { + const groups = createProjectGroups([ + { worktree: "/work/company/repo-a" }, + { worktree: "/work/company/repo-b" }, + { worktree: "/work/personal/repo-c" }, + ]) + + expect(groups.map((group) => group.id)).toEqual(["all", "/work/company", "/work/personal"]) + expect(groups[0]?.projects.length).toBe(3) + expect(groups[1]?.projects.map((project) => project.worktree)).toEqual([ + "/work/company/repo-a", + "/work/company/repo-b", + ]) + }) + + test("groups sub-projects under their selected core project", () => { + const groups = createProjectGroups( + [{ worktree: "/work/sub-a" }, { worktree: "/work/sub-b" }, { worktree: "/work/core", name: "Core" }], + { + "/work/sub-a": "/work/core", + "/work/sub-b": "/work/core", + }, + ) + + expect(groups.map((group) => group.id)).toEqual(["all", "project:/work/core"]) + expect(groups[1]?.label).toBe("Core") + expect(groups[1]?.projects.map((project) => project.worktree)).toEqual(["/work/core", "/work/sub-a", "/work/sub-b"]) + }) }) diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 42315e5893c..2dc1e8b9eb4 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,6 +1,14 @@ -import { getFilename } from "@opencode-ai/util/path" +import { getDirectory, getFilename } from "@opencode-ai/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" +export type ProjectGroup = { + id: string + label: string + projects: T[] +} + +const manualProjectGroupID = (directory: string) => `project:${workspaceKey(directory)}` + export const workspaceKey = (directory: string) => { const drive = directory.match(/^([A-Za-z]:)[\\/]+$/) if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}` @@ -99,4 +107,86 @@ export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted return [...result, ...live.values()] } +export const projectGroupID = (directory: string) => { + const parent = workspaceKey(getDirectory(workspaceKey(directory))) + return parent || workspaceKey(directory) +} + +export const projectGroupLabel = (id: string) => { + const name = getFilename(id) + if (name) return name + if (id === "/" || id === "\\") return id + return id.replace(/[\\/]+$/, "") +} + +export const createProjectGroups = ( + projects: T[], + parentByProject: Record = {}, +) => { + const normalizedParent = Object.entries(parentByProject).reduce( + (acc, [child, parent]) => { + const key = workspaceKey(child) + const root = workspaceKey(parent) + if (!key || !root || key === root) return acc + acc[key] = root + return acc + }, + {} as Record, + ) + + const parentSet = new Set(Object.values(normalizedParent)) + const projectByWorktree = new Map(projects.map((project) => [workspaceKey(project.worktree), project])) + const groups = new Map>() + for (const project of projects) { + const key = workspaceKey(project.worktree) + const parent = normalizedParent[key] + const id = parent + ? manualProjectGroupID(parent) + : parentSet.has(key) + ? manualProjectGroupID(key) + : projectGroupID(project.worktree) + + const group = groups.get(id) + if (group) { + group.projects.push(project) + continue + } + + const label = id.startsWith("project:") + ? displayName(projectByWorktree.get(id.slice("project:".length)) ?? { worktree: id.slice("project:".length) }) + : projectGroupLabel(id) + + groups.set(id, { + id, + label, + projects: [project], + }) + } + + const grouped = [...groups.values()].map((group) => { + if (!group.id.startsWith("project:")) return group + const root = group.id.slice("project:".length) + const projects = group.projects.slice().sort((a, b) => { + const aRoot = workspaceKey(a.worktree) === root + const bRoot = workspaceKey(b.worktree) === root + if (aRoot && !bRoot) return -1 + if (!aRoot && bRoot) return 1 + return 0 + }) + return { + ...group, + projects, + } + }) + + return [ + { + id: "all", + label: "All projects", + projects, + }, + ...grouped, + ] +} + export const syncWorkspaceOrder = effectiveWorkspaceOrder diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 8237e3c716f..f4406a384a9 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -28,6 +28,9 @@ export type ProjectSidebarContext = { navigateToProject: (directory: string) => void openSidebar: () => void closeProject: (directory: string) => void + addSubProject: (project: LocalProject) => void + removeSubProject: (project: LocalProject) => void + hasParentProject: (project: LocalProject) => boolean showEditProjectDialog: (project: LocalProject) => void toggleProjectWorkspaces: (project: LocalProject) => void workspacesEnabled: (project: LocalProject) => boolean @@ -67,6 +70,9 @@ const ProjectTile = (props: { onProjectMouseLeave: (worktree: string) => void onProjectFocus: (worktree: string) => void navigateToProject: (directory: string) => void + addSubProject: (project: LocalProject) => void + removeSubProject: (project: LocalProject) => void + hasParentProject: (project: LocalProject) => boolean showEditProjectDialog: (project: LocalProject) => void toggleProjectWorkspaces: (project: LocalProject) => void workspacesEnabled: (project: LocalProject) => boolean @@ -142,6 +148,26 @@ const ProjectTile = (props: { props.showEditProjectDialog(props.project)}> {props.language.t("common.edit")} + props.addSubProject(props.project)} + > + {props.language.t("sidebar.project.addSubProject")} + + } + > + props.removeSubProject(props.project)} + > + {props.language.t("sidebar.project.removeSubProject")} + + setState("menu", value)} setOpen={(value) => setState("open", value)} setSuppressHover={(value) => setState("suppressHover", value)} diff --git a/packages/app/src/pages/layout/sidebar-shell.tsx b/packages/app/src/pages/layout/sidebar-shell.tsx index d3070e37491..d70295dc04f 100644 --- a/packages/app/src/pages/layout/sidebar-shell.tsx +++ b/packages/app/src/pages/layout/sidebar-shell.tsx @@ -18,6 +18,7 @@ export const SidebarContent = (props: { opened: Accessor aimMove: (event: MouseEvent) => void projects: Accessor + isSubProject?: (project: LocalProject) => boolean renderProject: (project: LocalProject) => JSX.Element handleDragStart: (event: unknown) => void handleDragEnd: () => void @@ -35,6 +36,8 @@ export const SidebarContent = (props: { }): JSX.Element => { const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened())) const placement = () => (props.mobile ? "bottom" : "right") + const projects = createMemo(() => props.projects()) + let panel: HTMLDivElement | undefined createEffect(() => { @@ -64,8 +67,25 @@ export const SidebarContent = (props: {
- p.worktree)}> - {(project) => props.renderProject(project)} + p.worktree)}> + + {(project, index) => ( + <> + 0 && + !props.isSubProject?.(projects()[index() - 1] as LocalProject) + } + > +
+ +
+ {props.renderProject(project)} +
+ + )} + { return commands.wslPath("~", "windows").catch(() => undefined) } + const wslDialogPath = async (path?: string) => { + if (!path) return wslHome() + if (os !== "windows" || !window.__OPENCODE__?.wsl) return path + return commands.wslPath(path, "windows").catch(() => path) + } + const handleWslPicker = async (result: T | null): Promise => { if (!result || !window.__OPENCODE__?.wsl) return result if (Array.isArray(result)) { @@ -84,7 +90,7 @@ const createPlatform = (): Platform => { version: pkg.version, async openDirectoryPickerDialog(opts) { - const defaultPath = await wslHome() + const defaultPath = await wslDialogPath(opts?.defaultPath) const result = await open({ directory: true, multiple: opts?.multiple ?? false, diff --git a/packages/opencode/script/preload.js b/packages/opencode/script/preload.js new file mode 100644 index 00000000000..423d495f92c --- /dev/null +++ b/packages/opencode/script/preload.js @@ -0,0 +1 @@ +import "@opentui/solid/preload" diff --git a/script/check-preload.ts b/script/check-preload.ts new file mode 100644 index 00000000000..7e2fd6828c2 --- /dev/null +++ b/script/check-preload.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env bun + +import path from "path" + +const missing: string[] = [] + +for await (const file of new Bun.Glob("**/bunfig.toml").scan(".")) { + if (file.includes("/node_modules/")) continue + const text = await Bun.file(file).text() + const preload = [...text.matchAll(/preload\s*=\s*\[([\s\S]*?)\]/g)].flatMap((x) => { + if (!x[1]) return [] + return [...x[1].matchAll(/"([^"]+)"/g)].flatMap((y) => (y[1] ? [y[1]] : [])) + }) + + for (const item of preload) { + const target = path.resolve(path.dirname(file), item) + if (await Bun.file(target).exists()) continue + missing.push(`${file}: ${item}`) + } +} + +if (missing.length === 0) { + console.log("All Bun preload files exist") + process.exit(0) +} + +console.error("Missing Bun preload files:") +for (const item of missing) console.error(`- ${item}`) +process.exit(1) diff --git a/script/pr-create.ts b/script/pr-create.ts new file mode 100644 index 00000000000..67a63be4699 --- /dev/null +++ b/script/pr-create.ts @@ -0,0 +1,63 @@ +#!/usr/bin/env bun + +import path from "node:path" + +const need = [ + "### Issue for this PR", + "### Type of change", + "### What does this PR do?", + "### How did you verify your code works?", + "### Screenshots / recordings", + "### Checklist", +] + +const help = () => { + console.log(`Usage: bun run pr:create -- [gh pr create args] + +Required: + --body-file Path to PR body markdown file + +Examples: + bun run pr:create -- --base dev --title "feat: add foo" --body-file /tmp/pr.md + bun run pr:create -- --base dev --head my-branch --body-file .github/pull_request_template.md +`) +} + +const fail = (msg: string) => { + console.error(msg) + process.exit(1) +} + +const args = Bun.argv.slice(2) +if (args.includes("--help") || args.includes("-h")) { + help() + process.exit(0) +} + +const bodyIndex = args.findIndex((x) => x === "--body-file" || x === "-F") +if (bodyIndex === -1) fail("Missing --body-file/-F. This wrapper validates PR template before creating PR.") + +const bodyArg = args[bodyIndex + 1] +if (!bodyArg) fail("Missing value for --body-file/-F.") + +const bodyPath = path.resolve(process.cwd(), bodyArg) +const bodyFile = Bun.file(bodyPath) +if (!(await bodyFile.exists())) fail(`PR body file not found: ${bodyArg}`) + +const body = await bodyFile.text() +for (const section of need) { + if (body.includes(section)) continue + fail(`Missing required section: ${section}`) +} + +const checked = /- \[x\] (Bug fix|New feature|Refactor \/ code improvement|Documentation)/.test(body) +if (!checked) fail("No checked 'Type of change' checkbox found.") + +const run = Bun.spawnSync(["gh", "pr", "create", ...args], { + stdout: "inherit", + stderr: "inherit", + stdin: "inherit", + env: process.env, +}) + +process.exit(run.exitCode)