feat(workspace): add no-folder empty states and gate folder-only actions

- Sidebar: replace the "no conversations" placeholder with Open Folder, Clone Repository, and Project Boot buttons when the workspace has no open folders.
- Title bar: disable the terminal and auxiliary-panel toggle buttons while no folder is active.
- Aux panel: show a shared localized "no folder open" prompt in the file tree, git changes, and git log tabs when no folder is active.
- Add auxPanel.noFolderTitle / noFolderHint translations across all ten supported locales.
This commit is contained in:
xintaofei
2026-04-22 10:36:27 +08:00
parent 14fb231dcc
commit c691fb0c07
16 changed files with 158 additions and 12 deletions

View File

@@ -16,9 +16,12 @@ import { Virtualizer, type VirtualizerHandle } from "virtua"
import { import {
ChevronRight, ChevronRight,
Download, Download,
FolderOpen,
GitBranch,
ListChecks, ListChecks,
Loader2, Loader2,
Plus, Plus,
Rocket,
XCircle, XCircle,
} from "lucide-react" } from "lucide-react"
import { useActiveFolder } from "@/contexts/active-folder-context" import { useActiveFolder } from "@/contexts/active-folder-context"
@@ -28,10 +31,12 @@ import { useTaskContext } from "@/contexts/task-context"
import { useZoomLevel } from "@/hooks/use-appearance" import { useZoomLevel } from "@/hooks/use-appearance"
import { import {
importLocalConversations, importLocalConversations,
openProjectBootWindow,
updateConversationTitle, updateConversationTitle,
updateConversationStatus, updateConversationStatus,
deleteConversation, deleteConversation,
} from "@/lib/api" } from "@/lib/api"
import { isDesktop, openFileDialog } from "@/lib/platform"
import type { ConversationStatus, DbConversationSummary } from "@/lib/types" import type { ConversationStatus, DbConversationSummary } from "@/lib/types"
import { import {
loadFolderExpanded, loadFolderExpanded,
@@ -39,6 +44,8 @@ import {
} from "@/lib/sidebar-view-mode-storage" } from "@/lib/sidebar-view-mode-storage"
import { SidebarConversationCard } from "./sidebar-conversation-card" import { SidebarConversationCard } from "./sidebar-conversation-card"
import { ConversationManageDialog } from "./conversation-manage-dialog" import { ConversationManageDialog } from "./conversation-manage-dialog"
import { CloneDialog } from "@/components/layout/clone-dialog"
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
@@ -261,6 +268,7 @@ export function SidebarConversationList({
}) { }) {
const t = useTranslations("Folder.sidebar") const t = useTranslations("Folder.sidebar")
const tCommon = useTranslations("Folder.common") const tCommon = useTranslations("Folder.common")
const tFolderDropdown = useTranslations("Folder.folderNameDropdown")
const { zoomLevel } = useZoomLevel() const { zoomLevel } = useZoomLevel()
const safeZoomLevel = const safeZoomLevel =
typeof zoomLevel === "number" && Number.isFinite(zoomLevel) && zoomLevel > 0 typeof zoomLevel === "number" && Number.isFinite(zoomLevel) && zoomLevel > 0
@@ -271,6 +279,7 @@ export function SidebarConversationList({
Math.round((CARD_HEIGHT_REM * 16 * safeZoomLevel) / 100) Math.round((CARD_HEIGHT_REM * 16 * safeZoomLevel) / 100)
) )
const { const {
folders,
allFolders, allFolders,
conversations, conversations,
conversationsLoading: loading, conversationsLoading: loading,
@@ -278,6 +287,7 @@ export function SidebarConversationList({
refreshConversations, refreshConversations,
updateConversationLocal, updateConversationLocal,
removeFolderFromWorkspace, removeFolderFromWorkspace,
openFolder,
} = useAppWorkspace() } = useAppWorkspace()
const refreshing = loading const refreshing = loading
const { activeFolder } = useActiveFolder() const { activeFolder } = useActiveFolder()
@@ -320,6 +330,8 @@ export function SidebarConversationList({
folderId: number folderId: number
folderName: string folderName: string
} | null>(null) } | null>(null)
const [cloneOpen, setCloneOpen] = useState(false)
const [browserOpen, setBrowserOpen] = useState(false)
useEffect(() => { useEffect(() => {
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent. // Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
@@ -644,6 +656,45 @@ export function SidebarConversationList({
await handleImportForFolder(activeFolder.id) await handleImportForFolder(activeFolder.id)
}, [activeFolder, handleImportForFolder]) }, [activeFolder, handleImportForFolder])
const handleOpenFolderAction = useCallback(async () => {
if (isDesktop()) {
try {
const result = await openFileDialog({
directory: true,
multiple: false,
})
if (!result) return
const selected = Array.isArray(result) ? result[0] : result
await openFolder(selected)
} catch (err) {
console.error("[SidebarConversationList] failed to open folder:", err)
}
} else {
setBrowserOpen(true)
}
}, [openFolder])
const handleBrowserSelect = useCallback(
(path: string) => {
openFolder(path).catch((err) => {
console.error("[SidebarConversationList] failed to open folder:", err)
})
},
[openFolder]
)
const handleProjectBoot = useCallback(() => {
openProjectBootWindow().catch((err) => {
console.error(
"[SidebarConversationList] failed to open project boot:",
err
)
})
}, [])
const showEmptyWorkspaceActions =
folders.length === 0 && conversations.length === 0
const emptyAfterFilter = const emptyAfterFilter =
filteredConversations.length === 0 && conversations.length > 0 filteredConversations.length === 0 && conversations.length > 0
@@ -667,6 +718,36 @@ export function SidebarConversationList({
{t("error", { message: error })} {t("error", { message: error })}
</p> </p>
</div> </div>
) : showEmptyWorkspaceActions ? (
<div className="flex-1 flex flex-col items-center justify-center px-3 gap-2">
<Button
variant="outline"
size="sm"
className="w-full max-w-[14rem] justify-start"
onClick={handleOpenFolderAction}
>
<FolderOpen className="h-3.5 w-3.5 mr-1.5" />
{tFolderDropdown("openFolder")}
</Button>
<Button
variant="outline"
size="sm"
className="w-full max-w-[14rem] justify-start"
onClick={() => setCloneOpen(true)}
>
<GitBranch className="h-3.5 w-3.5 mr-1.5" />
{tFolderDropdown("cloneRepository")}
</Button>
<Button
variant="outline"
size="sm"
className="w-full max-w-[14rem] justify-start"
onClick={handleProjectBoot}
>
<Rocket className="h-3.5 w-3.5 mr-1.5" />
{tFolderDropdown("projectBoot")}
</Button>
</div>
) : conversations.length === 0 ? ( ) : conversations.length === 0 ? (
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
@@ -833,6 +914,13 @@ export function SidebarConversationList({
folderName={manageState.folderName} folderName={manageState.folderName}
/> />
)} )}
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
<DirectoryBrowserDialog
open={browserOpen}
onOpenChange={setBrowserOpen}
onSelect={handleBrowserSelect}
/>
</div> </div>
) )
} }

View File

@@ -19,6 +19,7 @@ import { useTabContext } from "@/contexts/tab-context"
import { useTerminalContext } from "@/contexts/terminal-context" import { useTerminalContext } from "@/contexts/terminal-context"
import { useWorkspaceContext } from "@/contexts/workspace-context" import { useWorkspaceContext } from "@/contexts/workspace-context"
import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store" import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store"
import { AuxPanelNoFolderEmpty } from "@/components/layout/aux-panel-no-folder-empty"
import { WorkspaceDegradedBanner } from "@/components/layout/workspace-degraded-banner" import { WorkspaceDegradedBanner } from "@/components/layout/workspace-degraded-banner"
import { import {
createFileTreeEntry, createFileTreeEntry,
@@ -2098,6 +2099,10 @@ export function FileTreeTab() {
workspaceState.seq, workspaceState.seq,
]) ])
if (!folder) {
return <AuxPanelNoFolderEmpty />
}
if (loading && nodes.length === 0) { if (loading && nodes.length === 0) {
return ( return (
<div className="p-3 space-y-2"> <div className="p-3 space-y-2">

View File

@@ -38,6 +38,7 @@ import { useActiveFolder } from "@/contexts/active-folder-context"
import { useTabContext } from "@/contexts/tab-context" import { useTabContext } from "@/contexts/tab-context"
import { useWorkspaceContext } from "@/contexts/workspace-context" import { useWorkspaceContext } from "@/contexts/workspace-context"
import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store" import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store"
import { AuxPanelNoFolderEmpty } from "@/components/layout/aux-panel-no-folder-empty"
import { WorkspaceDegradedBanner } from "@/components/layout/workspace-degraded-banner" import { WorkspaceDegradedBanner } from "@/components/layout/workspace-degraded-banner"
import { import {
deleteFileTreeEntry, deleteFileTreeEntry,
@@ -1166,6 +1167,10 @@ export function GitChangesTab() {
] ]
) )
if (!folder) {
return <AuxPanelNoFolderEmpty />
}
if (loading) { if (loading) {
return ( return (
<div className="p-2 space-y-2"> <div className="p-2 space-y-2">

View File

@@ -76,6 +76,7 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible" } from "@/components/ui/collapsible"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { AuxPanelNoFolderEmpty } from "@/components/layout/aux-panel-no-folder-empty"
import { subscribe } from "@/lib/platform" import { subscribe } from "@/lib/platform"
import { useActiveFolder } from "@/contexts/active-folder-context" import { useActiveFolder } from "@/contexts/active-folder-context"
import { useWorkspaceContext } from "@/contexts/workspace-context" import { useWorkspaceContext } from "@/contexts/workspace-context"
@@ -1024,6 +1025,10 @@ export function GitLogTab() {
setScrolled((prev) => (prev === nextScrolled ? prev : nextScrolled)) setScrolled((prev) => (prev === nextScrolled ? prev : nextScrolled))
}, []) }, [])
if (!folder) {
return <AuxPanelNoFolderEmpty />
}
if (loading) { if (loading) {
return ( return (
<ScrollArea className="h-full px-3 py-3"> <ScrollArea className="h-full px-3 py-3">

View File

@@ -0,0 +1,15 @@
"use client"
import { FolderOpen } from "lucide-react"
import { useTranslations } from "next-intl"
export function AuxPanelNoFolderEmpty() {
const t = useTranslations("Folder.auxPanel")
return (
<div className="flex h-full flex-col items-center justify-center gap-1 p-6 text-center">
<FolderOpen className="size-5 text-muted-foreground/60" aria-hidden />
<p className="text-sm font-medium">{t("noFolderTitle")}</p>
<p className="text-xs text-muted-foreground">{t("noFolderHint")}</p>
</div>
)
}

View File

@@ -344,11 +344,17 @@ export function FolderTitleBar() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={toggleAuxPanel}> <DropdownMenuItem
onClick={toggleAuxPanel}
disabled={!activeFolder}
>
<PanelRight className="h-3.5 w-3.5" /> <PanelRight className="h-3.5 w-3.5" />
{tTitleBar("toggleAuxPanel")} {tTitleBar("toggleAuxPanel")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => toggleTerminal()}> <DropdownMenuItem
onClick={() => toggleTerminal()}
disabled={!activeFolder}
>
<SquareTerminal className="h-3.5 w-3.5" /> <SquareTerminal className="h-3.5 w-3.5" />
{tTitleBar("toggleTerminal")} {tTitleBar("toggleTerminal")}
</DropdownMenuItem> </DropdownMenuItem>
@@ -370,6 +376,7 @@ export function FolderTitleBar() {
size="icon" size="icon"
className={`h-6 w-6 hover:text-foreground/80 ${terminalOpen ? "bg-accent" : ""}`} className={`h-6 w-6 hover:text-foreground/80 ${terminalOpen ? "bg-accent" : ""}`}
onClick={() => toggleTerminal()} onClick={() => toggleTerminal()}
disabled={!activeFolder}
title={tTitleBar("withShortcut", { title={tTitleBar("withShortcut", {
label: tTitleBar("toggleTerminal"), label: tTitleBar("toggleTerminal"),
shortcut: formatShortcutLabel( shortcut: formatShortcutLabel(
@@ -385,6 +392,7 @@ export function FolderTitleBar() {
size="icon" size="icon"
className={`h-6 w-6 hover:text-foreground/80 ${auxPanelOpen ? "bg-accent" : ""}`} className={`h-6 w-6 hover:text-foreground/80 ${auxPanelOpen ? "bg-accent" : ""}`}
onClick={toggleAuxPanel} onClick={toggleAuxPanel}
disabled={!activeFolder}
title={tTitleBar("withShortcut", { title={tTitleBar("withShortcut", {
label: tTitleBar("toggleAuxPanel"), label: tTitleBar("toggleAuxPanel"),
shortcut: formatShortcutLabel( shortcut: formatShortcutLabel(

View File

@@ -935,7 +935,9 @@
"files": "الملفات", "files": "الملفات",
"changes": "التغييرات", "changes": "التغييرات",
"commits": "الالتزامات" "commits": "الالتزامات"
} },
"noFolderTitle": "لا يوجد مجلد مفتوح",
"noFolderHint": "افتح مجلدا لعرض محتواه هنا"
}, },
"windowControls": { "windowControls": {
"minimizeWindow": "تصغير النافذة", "minimizeWindow": "تصغير النافذة",

View File

@@ -935,7 +935,9 @@
"files": "Dateien", "files": "Dateien",
"changes": "Änderungen", "changes": "Änderungen",
"commits": "Einträge" "commits": "Einträge"
} },
"noFolderTitle": "Kein Ordner geöffnet",
"noFolderHint": "Öffnen Sie einen Ordner, um seinen Inhalt hier zu sehen"
}, },
"windowControls": { "windowControls": {
"minimizeWindow": "Fenster minimieren", "minimizeWindow": "Fenster minimieren",

View File

@@ -935,7 +935,9 @@
"files": "Files", "files": "Files",
"changes": "Changes", "changes": "Changes",
"commits": "Commits" "commits": "Commits"
} },
"noFolderTitle": "No folder open",
"noFolderHint": "Open a folder to see its contents here"
}, },
"windowControls": { "windowControls": {
"minimizeWindow": "Minimize window", "minimizeWindow": "Minimize window",

View File

@@ -935,7 +935,9 @@
"files": "Archivos", "files": "Archivos",
"changes": "Cambios", "changes": "Cambios",
"commits": "Confirmaciones" "commits": "Confirmaciones"
} },
"noFolderTitle": "No hay carpeta abierta",
"noFolderHint": "Abre una carpeta para ver su contenido aquí"
}, },
"windowControls": { "windowControls": {
"minimizeWindow": "Minimizar ventana", "minimizeWindow": "Minimizar ventana",

View File

@@ -935,7 +935,9 @@
"files": "Fichiers", "files": "Fichiers",
"changes": "Changements", "changes": "Changements",
"commits": "Validations" "commits": "Validations"
} },
"noFolderTitle": "Aucun dossier ouvert",
"noFolderHint": "Ouvrez un dossier pour voir son contenu ici"
}, },
"windowControls": { "windowControls": {
"minimizeWindow": "Minimiser la fenêtre", "minimizeWindow": "Minimiser la fenêtre",

View File

@@ -935,7 +935,9 @@
"files": "ファイル", "files": "ファイル",
"changes": "変更", "changes": "変更",
"commits": "コミット" "commits": "コミット"
} },
"noFolderTitle": "開いているフォルダがありません",
"noFolderHint": "フォルダを開くとここに表示されます"
}, },
"windowControls": { "windowControls": {
"minimizeWindow": "ウィンドウを最小化", "minimizeWindow": "ウィンドウを最小化",

View File

@@ -935,7 +935,9 @@
"files": "파일", "files": "파일",
"changes": "변경사항", "changes": "변경사항",
"commits": "커밋" "commits": "커밋"
} },
"noFolderTitle": "열린 폴더 없음",
"noFolderHint": "폴더를 열면 여기에 표시됩니다"
}, },
"windowControls": { "windowControls": {
"minimizeWindow": "창 최소화", "minimizeWindow": "창 최소화",

View File

@@ -935,7 +935,9 @@
"files": "Arquivos", "files": "Arquivos",
"changes": "Alterações", "changes": "Alterações",
"commits": "Confirmações" "commits": "Confirmações"
} },
"noFolderTitle": "Nenhuma pasta aberta",
"noFolderHint": "Abra uma pasta para ver seu conteúdo aqui"
}, },
"windowControls": { "windowControls": {
"minimizeWindow": "Minimizar janela", "minimizeWindow": "Minimizar janela",

View File

@@ -935,7 +935,9 @@
"files": "文件", "files": "文件",
"changes": "变更", "changes": "变更",
"commits": "提交" "commits": "提交"
} },
"noFolderTitle": "暂无打开的文件夹",
"noFolderHint": "打开一个文件夹后在这里查看"
}, },
"windowControls": { "windowControls": {
"minimizeWindow": "最小化窗口", "minimizeWindow": "最小化窗口",

View File

@@ -935,7 +935,9 @@
"files": "檔案", "files": "檔案",
"changes": "變更", "changes": "變更",
"commits": "提交" "commits": "提交"
} },
"noFolderTitle": "尚無開啟的資料夾",
"noFolderHint": "開啟一個資料夾後可在此查看"
}, },
"windowControls": { "windowControls": {
"minimizeWindow": "最小化視窗", "minimizeWindow": "最小化視窗",