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:
@@ -16,9 +16,12 @@ import { Virtualizer, type VirtualizerHandle } from "virtua"
|
||||
import {
|
||||
ChevronRight,
|
||||
Download,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
ListChecks,
|
||||
Loader2,
|
||||
Plus,
|
||||
Rocket,
|
||||
XCircle,
|
||||
} from "lucide-react"
|
||||
import { useActiveFolder } from "@/contexts/active-folder-context"
|
||||
@@ -28,10 +31,12 @@ import { useTaskContext } from "@/contexts/task-context"
|
||||
import { useZoomLevel } from "@/hooks/use-appearance"
|
||||
import {
|
||||
importLocalConversations,
|
||||
openProjectBootWindow,
|
||||
updateConversationTitle,
|
||||
updateConversationStatus,
|
||||
deleteConversation,
|
||||
} from "@/lib/api"
|
||||
import { isDesktop, openFileDialog } from "@/lib/platform"
|
||||
import type { ConversationStatus, DbConversationSummary } from "@/lib/types"
|
||||
import {
|
||||
loadFolderExpanded,
|
||||
@@ -39,6 +44,8 @@ import {
|
||||
} from "@/lib/sidebar-view-mode-storage"
|
||||
import { SidebarConversationCard } from "./sidebar-conversation-card"
|
||||
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 { Skeleton } from "@/components/ui/skeleton"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
@@ -261,6 +268,7 @@ export function SidebarConversationList({
|
||||
}) {
|
||||
const t = useTranslations("Folder.sidebar")
|
||||
const tCommon = useTranslations("Folder.common")
|
||||
const tFolderDropdown = useTranslations("Folder.folderNameDropdown")
|
||||
const { zoomLevel } = useZoomLevel()
|
||||
const safeZoomLevel =
|
||||
typeof zoomLevel === "number" && Number.isFinite(zoomLevel) && zoomLevel > 0
|
||||
@@ -271,6 +279,7 @@ export function SidebarConversationList({
|
||||
Math.round((CARD_HEIGHT_REM * 16 * safeZoomLevel) / 100)
|
||||
)
|
||||
const {
|
||||
folders,
|
||||
allFolders,
|
||||
conversations,
|
||||
conversationsLoading: loading,
|
||||
@@ -278,6 +287,7 @@ export function SidebarConversationList({
|
||||
refreshConversations,
|
||||
updateConversationLocal,
|
||||
removeFolderFromWorkspace,
|
||||
openFolder,
|
||||
} = useAppWorkspace()
|
||||
const refreshing = loading
|
||||
const { activeFolder } = useActiveFolder()
|
||||
@@ -320,6 +330,8 @@ export function SidebarConversationList({
|
||||
folderId: number
|
||||
folderName: string
|
||||
} | null>(null)
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
const [browserOpen, setBrowserOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Hydrate from localStorage after mount to keep SSR/CSR markup consistent.
|
||||
@@ -644,6 +656,45 @@ export function SidebarConversationList({
|
||||
await handleImportForFolder(activeFolder.id)
|
||||
}, [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 =
|
||||
filteredConversations.length === 0 && conversations.length > 0
|
||||
|
||||
@@ -667,6 +718,36 @@ export function SidebarConversationList({
|
||||
{t("error", { message: error })}
|
||||
</p>
|
||||
</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 ? (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
@@ -833,6 +914,13 @@ export function SidebarConversationList({
|
||||
folderName={manageState.folderName}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
|
||||
<DirectoryBrowserDialog
|
||||
open={browserOpen}
|
||||
onOpenChange={setBrowserOpen}
|
||||
onSelect={handleBrowserSelect}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useTerminalContext } from "@/contexts/terminal-context"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
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 {
|
||||
createFileTreeEntry,
|
||||
@@ -2098,6 +2099,10 @@ export function FileTreeTab() {
|
||||
workspaceState.seq,
|
||||
])
|
||||
|
||||
if (!folder) {
|
||||
return <AuxPanelNoFolderEmpty />
|
||||
}
|
||||
|
||||
if (loading && nodes.length === 0) {
|
||||
return (
|
||||
<div className="p-3 space-y-2">
|
||||
|
||||
@@ -38,6 +38,7 @@ import { useActiveFolder } from "@/contexts/active-folder-context"
|
||||
import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
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 {
|
||||
deleteFileTreeEntry,
|
||||
@@ -1166,6 +1167,10 @@ export function GitChangesTab() {
|
||||
]
|
||||
)
|
||||
|
||||
if (!folder) {
|
||||
return <AuxPanelNoFolderEmpty />
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-2 space-y-2">
|
||||
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { AuxPanelNoFolderEmpty } from "@/components/layout/aux-panel-no-folder-empty"
|
||||
import { subscribe } from "@/lib/platform"
|
||||
import { useActiveFolder } from "@/contexts/active-folder-context"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
@@ -1024,6 +1025,10 @@ export function GitLogTab() {
|
||||
setScrolled((prev) => (prev === nextScrolled ? prev : nextScrolled))
|
||||
}, [])
|
||||
|
||||
if (!folder) {
|
||||
return <AuxPanelNoFolderEmpty />
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ScrollArea className="h-full px-3 py-3">
|
||||
|
||||
15
src/components/layout/aux-panel-no-folder-empty.tsx
Normal file
15
src/components/layout/aux-panel-no-folder-empty.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -344,11 +344,17 @@ export function FolderTitleBar() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={toggleAuxPanel}>
|
||||
<DropdownMenuItem
|
||||
onClick={toggleAuxPanel}
|
||||
disabled={!activeFolder}
|
||||
>
|
||||
<PanelRight className="h-3.5 w-3.5" />
|
||||
{tTitleBar("toggleAuxPanel")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => toggleTerminal()}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toggleTerminal()}
|
||||
disabled={!activeFolder}
|
||||
>
|
||||
<SquareTerminal className="h-3.5 w-3.5" />
|
||||
{tTitleBar("toggleTerminal")}
|
||||
</DropdownMenuItem>
|
||||
@@ -370,6 +376,7 @@ export function FolderTitleBar() {
|
||||
size="icon"
|
||||
className={`h-6 w-6 hover:text-foreground/80 ${terminalOpen ? "bg-accent" : ""}`}
|
||||
onClick={() => toggleTerminal()}
|
||||
disabled={!activeFolder}
|
||||
title={tTitleBar("withShortcut", {
|
||||
label: tTitleBar("toggleTerminal"),
|
||||
shortcut: formatShortcutLabel(
|
||||
@@ -385,6 +392,7 @@ export function FolderTitleBar() {
|
||||
size="icon"
|
||||
className={`h-6 w-6 hover:text-foreground/80 ${auxPanelOpen ? "bg-accent" : ""}`}
|
||||
onClick={toggleAuxPanel}
|
||||
disabled={!activeFolder}
|
||||
title={tTitleBar("withShortcut", {
|
||||
label: tTitleBar("toggleAuxPanel"),
|
||||
shortcut: formatShortcutLabel(
|
||||
|
||||
@@ -935,7 +935,9 @@
|
||||
"files": "الملفات",
|
||||
"changes": "التغييرات",
|
||||
"commits": "الالتزامات"
|
||||
}
|
||||
},
|
||||
"noFolderTitle": "لا يوجد مجلد مفتوح",
|
||||
"noFolderHint": "افتح مجلدا لعرض محتواه هنا"
|
||||
},
|
||||
"windowControls": {
|
||||
"minimizeWindow": "تصغير النافذة",
|
||||
|
||||
@@ -935,7 +935,9 @@
|
||||
"files": "Dateien",
|
||||
"changes": "Änderungen",
|
||||
"commits": "Einträge"
|
||||
}
|
||||
},
|
||||
"noFolderTitle": "Kein Ordner geöffnet",
|
||||
"noFolderHint": "Öffnen Sie einen Ordner, um seinen Inhalt hier zu sehen"
|
||||
},
|
||||
"windowControls": {
|
||||
"minimizeWindow": "Fenster minimieren",
|
||||
|
||||
@@ -935,7 +935,9 @@
|
||||
"files": "Files",
|
||||
"changes": "Changes",
|
||||
"commits": "Commits"
|
||||
}
|
||||
},
|
||||
"noFolderTitle": "No folder open",
|
||||
"noFolderHint": "Open a folder to see its contents here"
|
||||
},
|
||||
"windowControls": {
|
||||
"minimizeWindow": "Minimize window",
|
||||
|
||||
@@ -935,7 +935,9 @@
|
||||
"files": "Archivos",
|
||||
"changes": "Cambios",
|
||||
"commits": "Confirmaciones"
|
||||
}
|
||||
},
|
||||
"noFolderTitle": "No hay carpeta abierta",
|
||||
"noFolderHint": "Abre una carpeta para ver su contenido aquí"
|
||||
},
|
||||
"windowControls": {
|
||||
"minimizeWindow": "Minimizar ventana",
|
||||
|
||||
@@ -935,7 +935,9 @@
|
||||
"files": "Fichiers",
|
||||
"changes": "Changements",
|
||||
"commits": "Validations"
|
||||
}
|
||||
},
|
||||
"noFolderTitle": "Aucun dossier ouvert",
|
||||
"noFolderHint": "Ouvrez un dossier pour voir son contenu ici"
|
||||
},
|
||||
"windowControls": {
|
||||
"minimizeWindow": "Minimiser la fenêtre",
|
||||
|
||||
@@ -935,7 +935,9 @@
|
||||
"files": "ファイル",
|
||||
"changes": "変更",
|
||||
"commits": "コミット"
|
||||
}
|
||||
},
|
||||
"noFolderTitle": "開いているフォルダがありません",
|
||||
"noFolderHint": "フォルダを開くとここに表示されます"
|
||||
},
|
||||
"windowControls": {
|
||||
"minimizeWindow": "ウィンドウを最小化",
|
||||
|
||||
@@ -935,7 +935,9 @@
|
||||
"files": "파일",
|
||||
"changes": "변경사항",
|
||||
"commits": "커밋"
|
||||
}
|
||||
},
|
||||
"noFolderTitle": "열린 폴더 없음",
|
||||
"noFolderHint": "폴더를 열면 여기에 표시됩니다"
|
||||
},
|
||||
"windowControls": {
|
||||
"minimizeWindow": "창 최소화",
|
||||
|
||||
@@ -935,7 +935,9 @@
|
||||
"files": "Arquivos",
|
||||
"changes": "Alterações",
|
||||
"commits": "Confirmações"
|
||||
}
|
||||
},
|
||||
"noFolderTitle": "Nenhuma pasta aberta",
|
||||
"noFolderHint": "Abra uma pasta para ver seu conteúdo aqui"
|
||||
},
|
||||
"windowControls": {
|
||||
"minimizeWindow": "Minimizar janela",
|
||||
|
||||
@@ -935,7 +935,9 @@
|
||||
"files": "文件",
|
||||
"changes": "变更",
|
||||
"commits": "提交"
|
||||
}
|
||||
},
|
||||
"noFolderTitle": "暂无打开的文件夹",
|
||||
"noFolderHint": "打开一个文件夹后在这里查看"
|
||||
},
|
||||
"windowControls": {
|
||||
"minimizeWindow": "最小化窗口",
|
||||
|
||||
@@ -935,7 +935,9 @@
|
||||
"files": "檔案",
|
||||
"changes": "變更",
|
||||
"commits": "提交"
|
||||
}
|
||||
},
|
||||
"noFolderTitle": "尚無開啟的資料夾",
|
||||
"noFolderHint": "開啟一個資料夾後可在此查看"
|
||||
},
|
||||
"windowControls": {
|
||||
"minimizeWindow": "最小化視窗",
|
||||
|
||||
Reference in New Issue
Block a user