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:
@@ -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
33
src/lib/folder-badge.ts
Normal 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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
55
src/lib/sidebar-view-mode-storage.ts
Normal file
55
src/lib/sidebar-view-mode-storage.ts
Normal 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 */
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user