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(

33
src/lib/folder-badge.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* Stable folder color + initial derivation for multi-folder visual identity
* across tab bar, terminal tab bar, and sidebar conversation cards.
*/
const FOLDER_COLORS = [
"bg-red-500",
"bg-orange-500",
"bg-amber-500",
"bg-yellow-500",
"bg-lime-500",
"bg-green-500",
"bg-emerald-500",
"bg-teal-500",
"bg-cyan-500",
"bg-sky-500",
"bg-blue-500",
"bg-indigo-500",
"bg-violet-500",
"bg-purple-500",
"bg-fuchsia-500",
"bg-pink-500",
] as const
export function folderBadgeColor(folderId: number): string {
return FOLDER_COLORS[Math.abs(folderId) % FOLDER_COLORS.length]
}
export function folderBadgeLabel(name: string): string {
if (!name) return "?"
const match = name.match(/^(\p{L}|\p{N})/u)
return (match ? match[1] : name.slice(0, 1)).toUpperCase()
}

View File

@@ -90,9 +90,9 @@ export function buildConversationDraftStorageKey(
}
export function buildNewConversationDraftStorageKey(params: {
folderId: number
tabId: string
}): string {
return `new:${params.folderId}`
return `new:${params.tabId}`
}
export function loadMessageInputDraft(draftKey: string): string | null {

View File

@@ -0,0 +1,55 @@
"use client"
export type SidebarViewMode = "flat" | "grouped"
const VIEW_MODE_KEY = "workspace:sidebar-view-mode"
const FOLDER_EXPANDED_KEY = "workspace:sidebar-folder-expanded"
export function loadSidebarViewMode(): SidebarViewMode {
if (typeof window === "undefined") return "flat"
try {
const raw = localStorage.getItem(VIEW_MODE_KEY)
if (raw === "flat" || raw === "grouped") return raw
} catch {
/* ignore */
}
return "flat"
}
export function saveSidebarViewMode(mode: SidebarViewMode): void {
if (typeof window === "undefined") return
try {
localStorage.setItem(VIEW_MODE_KEY, mode)
} catch {
/* ignore */
}
}
export function loadFolderExpanded(): Record<number, boolean> {
if (typeof window === "undefined") return {}
try {
const raw = localStorage.getItem(FOLDER_EXPANDED_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== "object") return {}
const result: Record<number, boolean> = {}
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
const id = Number(k)
if (!Number.isNaN(id) && typeof v === "boolean") {
result[id] = v
}
}
return result
} catch {
return {}
}
}
export function saveFolderExpanded(state: Record<number, boolean>): void {
if (typeof window === "undefined") return
try {
localStorage.setItem(FOLDER_EXPANDED_KEY, JSON.stringify(state))
} catch {
/* ignore */
}
}

View File

@@ -19,7 +19,7 @@ import type {
FolderDetail,
DbConversationSummary,
ImportResult,
OpenedConversation,
OpenedTab,
GitStatusEntry,
GitBranchList,
GitPullResult,
@@ -470,22 +470,44 @@ export async function getFolder(folderId: number): Promise<FolderDetail> {
return invoke("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 invoke("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 invoke("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 invoke("list_opened_tabs")
}
export async function saveOpenedTabs(items: OpenedTab[]): Promise<void> {
return invoke("save_opened_tabs", { items })
}
export async function listOpenFolderDetails(): Promise<FolderDetail[]> {
return invoke("list_open_folder_details")
}
export async function openFolderById(folderId: number): Promise<FolderDetail> {
return invoke("open_folder_by_id", { folderId })
}
export async function removeFolderFromWorkspace(
folderId: number
): Promise<void> {
return invoke("remove_folder_from_workspace", { folderId })
}
export async function importLocalConversations(
folderId: number
): Promise<ImportResult> {
@@ -498,13 +520,6 @@ export async function getFolderConversation(
return invoke("get_folder_conversation", { conversationId })
}
export async function saveFolderOpenedConversations(
folderId: number,
items: OpenedConversation[]
): Promise<void> {
return invoke("save_folder_opened_conversations", { folderId, items })
}
export async function setFolderParentBranch(
path: string,
parentBranch: string | null
@@ -858,8 +873,8 @@ export async function gitAddFiles(
// Window management commands
export async function openFolderWindow(path: string): Promise<void> {
return invoke("open_folder_window", { path })
export async function openFolder(path: string): Promise<FolderDetail> {
return invoke("open_folder", { path })
}
export async function openCommitWindow(folderId: number): Promise<void> {
@@ -888,14 +903,6 @@ export async function openSettingsWindow(
})
}
export async function listOpenFolders(): Promise<FolderHistoryEntry[]> {
return invoke("list_open_folders")
}
export async function focusFolderWindow(folderId: number): Promise<void> {
return invoke("focus_folder_window", { folderId })
}
// Conversation CRUD commands
export async function createConversation(

View File

@@ -163,11 +163,12 @@ export interface FolderDetail {
parent_branch: string | null
default_agent_type: AgentType | null
last_opened_at: string
opened_conversations: OpenedConversation[]
}
export interface OpenedConversation {
conversation_id: number
export interface OpenedTab {
id: number
folder_id: number
conversation_id: number | null
agent_type: AgentType
position: number
is_active: boolean