Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
17 changes: 3 additions & 14 deletions packages/app/e2e/projects/projects-switch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(?:[/?#]|$)`))

Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/context/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Expand Down
15 changes: 15 additions & 0 deletions packages/app/src/index.css
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
165 changes: 147 additions & 18 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>,
workspaceOrder: {} as Record<string, string[]>,
workspaceName: {} as Record<string, string>,
workspaceBranchName: {} as Record<string, Record<string, string>>,
Expand Down Expand Up @@ -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<string, string>,
)
})

const projectParentSet = createMemo(() => new Set(Object.values(projectParent())))
const subProjectSet = createMemo(() => new Set(Object.keys(projectParent())))

const subProjectsByParent = createMemo(() => {
const map = new Map<string, LocalProject[]>()
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) {
Expand All @@ -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<string, string>,
)

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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1330,30 +1413,71 @@ export default function Layout(props: ParentProps) {

const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />)

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(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => resolve(null),
)
return
}

dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => 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) => {
Expand Down Expand Up @@ -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)(),
Expand Down Expand Up @@ -2140,7 +2267,8 @@ export default function Layout(props: ParentProps) {
<SidebarContent
opened={() => layout.sidebar.opened()}
aimMove={aim.move}
projects={() => layout.projects.list()}
projects={groupedProjects}
isSubProject={(project) => subProjectSet().has(workspaceKey(project.worktree))}
renderProject={(project) => (
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} />
)}
Expand Down Expand Up @@ -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) => (
<SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile />
)}
Expand Down
54 changes: 52 additions & 2 deletions packages/app/src/pages/layout/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Session> & Pick<Session, "id" | "directory">) =>
({
Expand Down Expand Up @@ -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"])
})
})
Loading
Loading