refactor(workspace): migrate from per-folder windows to single-window workspace

Replace the legacy folder + welcome routes with a unified /workspace route
that hosts all folders, conversations, tabs, and terminals in one window.

- Persist opened tabs to the database (opened_tabs entity + migration)
  so tab layout survives restarts and deep-link bootstrap restores state
- Replace FolderContext shim with AppWorkspaceProvider, ActiveFolderProvider,
  and TabProvider; expose both opened (folders) and full DB (allFolders)
  listings via list_all_folder_details
- Return conversations across all non-deleted folders from list_all when
  no folder filter is given, so the sidebar can show every folder's history
- Add ConversationContextBar above the chat input with folder picker
  (auto-opens unopened folders on select), branch picker, and commit /
  push / merge / stash entries to restore BranchDropdown functionality
- Rework sidebar with stats header, search, flat / folder-grouped view
  modes (localStorage-persisted), reveal-in-sidebar event subscriber,
  and per-folder context menu (focus, close tabs, remove from workspace);
  indent conversations under folder headers in grouped mode
- Gate terminal creation on active folder and show folder context
- Remove deprecated BranchDropdown, FolderNameDropdown, welcome route,
  and per-folder window commands
- Localize all new strings across 10 locales
This commit is contained in:
xintaofei
2026-04-20 21:22:36 +08:00
parent 10801bf393
commit d9323d7399
89 changed files with 3701 additions and 2743 deletions

View File

@@ -21,7 +21,7 @@ import type {
FolderDetail,
DbConversationSummary,
ImportResult,
OpenedConversation,
OpenedTab,
GitStatusEntry,
GitBranchList,
GitPullResult,
@@ -598,22 +598,48 @@ export async function getFolder(folderId: number): Promise<FolderDetail> {
return getTransport().call("get_folder", { folderId })
}
export async function listFolderConversations(params: {
folder_id: number
export async function listAllConversations(params?: {
folder_ids?: number[] | null
agent_type?: AgentType | null
search?: string | null
sort_by?: string | null
status?: string | null
}): Promise<DbConversationSummary[]> {
return getTransport().call("list_folder_conversations", {
folderId: params.folder_id,
agentType: params.agent_type ?? null,
search: params.search ?? null,
sortBy: params.sort_by ?? null,
status: params.status ?? null,
return getTransport().call("list_all_conversations", {
folderIds: params?.folder_ids ?? null,
agentType: params?.agent_type ?? null,
search: params?.search ?? null,
sortBy: params?.sort_by ?? null,
status: params?.status ?? null,
})
}
export async function listOpenedTabs(): Promise<OpenedTab[]> {
return getTransport().call("list_opened_tabs")
}
export async function saveOpenedTabs(items: OpenedTab[]): Promise<void> {
return getTransport().call("save_opened_tabs", { items })
}
export async function listOpenFolderDetails(): Promise<FolderDetail[]> {
return getTransport().call("list_open_folder_details")
}
export async function listAllFolderDetails(): Promise<FolderDetail[]> {
return getTransport().call("list_all_folder_details")
}
export async function openFolderById(folderId: number): Promise<FolderDetail> {
return getTransport().call("open_folder_by_id", { folderId })
}
export async function removeFolderFromWorkspace(
folderId: number
): Promise<void> {
return getTransport().call("remove_folder_from_workspace", { folderId })
}
export async function importLocalConversations(
folderId: number
): Promise<ImportResult> {
@@ -626,16 +652,6 @@ export async function getFolderConversation(
return getTransport().call("get_folder_conversation", { conversationId })
}
export async function saveFolderOpenedConversations(
folderId: number,
items: OpenedConversation[]
): Promise<void> {
return getTransport().call("save_folder_opened_conversations", {
folderId,
items,
})
}
export async function setFolderParentBranch(
path: string,
parentBranch: string | null
@@ -1053,23 +1069,8 @@ export async function gitAddFiles(
// Window management commands
export async function openFolderWindow(
path: string,
options?: { newWindow?: boolean }
): Promise<void> {
if (getTransport().isDesktop()) {
return getTransport().call("open_folder_window", { path })
}
const entry = await getTransport().call<{ id: number }>(
"open_folder_window",
{ path }
)
const url = `/folder?id=${entry.id}`
if (options?.newWindow) {
window.open(url, `folder-${entry.id}`)
} else {
window.location.href = url
}
export async function openFolder(path: string): Promise<FolderDetail> {
return getTransport().call("open_folder", { path })
}
export async function openCommitWindow(folderId: number): Promise<void> {
@@ -1120,7 +1121,9 @@ export async function openProjectBootWindow(source?: string): Promise<void> {
if (getTransport().isDesktop()) {
return getTransport().call("open_project_boot_window", { source })
}
window.open("/project-boot", "project-boot")
if (typeof window !== "undefined") {
window.open("/project-boot", "project-boot")
}
}
export async function detectPackageManager(
@@ -1145,27 +1148,6 @@ export async function createShadcnProject(params: {
})
}
export async function listOpenFolders(): Promise<FolderHistoryEntry[]> {
return getTransport().call("list_open_folders")
}
export async function focusFolderWindow(folderId: number): Promise<void> {
if (getTransport().isDesktop()) {
return getTransport().call("focus_folder_window", { folderId })
}
// Web mode: open empty string to focus existing named window without reload.
// If the window doesn't exist (was closed), open the folder page.
const win = window.open("", `folder-${folderId}`)
if (
!win ||
win.closed ||
!win.location.href ||
win.location.href === "about:blank"
) {
window.open(`/folder?id=${folderId}`, `folder-${folderId}`)
}
}
// Conversation CRUD commands
export async function createConversation(