Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,15 @@ pub fn run() {
api.prevent_close();
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(tauri::generate_context!())
.expect("error while running tauri application")
.run(|app_handle, event| {
// 所有退出路径(托盘 Quit / macOS Cmd+Q / 自更新 relaunch)最终都汇聚到
// RunEvent::Exit,在此统一移除托盘图标,规避 macOS 菜单栏进程被系统复活。
if let tauri::RunEvent::Exit = event {
tray::remove_trays(app_handle);
}
});
}

/// debug/test 专用集成测试入口:让 `src-tauri/tests/` 能调用内部 command 实现与共享 helper。
Expand Down
9 changes: 9 additions & 0 deletions src-tauri/src/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1273,6 +1273,15 @@ pub fn setup_tray(app: &tauri::App) -> tauri::Result<()> {
Ok(())
}

/// 移除两个状态栏托盘图标。macOS 上应用退出前必须调用:否则 FrontBoard 会把菜单栏
/// 应用标记为 after-life.interrupted 并通过 MenuBarAgent 复活以恢复状态栏图标,
/// 表现为“退出后自动重开”。挂在 RunEvent::Exit 上覆盖托盘 Quit / Cmd+Q / 自更新
/// relaunch 等所有退出路径;非 macOS 平台调用无副作用(进程本就在退出)。
pub(crate) fn remove_trays(app: &tauri::AppHandle) {
let _ = app.remove_tray_by_id(MAIN_TRAY_ID);
let _ = app.remove_tray_by_id(SESSIONS_TRAY_ID);
}

#[cfg(test)]
mod tests {
#[cfg(target_os = "macos")]
Expand Down
186 changes: 85 additions & 101 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
LIST_PANEL_WIDTH_CLASS,
} from "./components/layout-size-classes";
import Sidebar from "./components/Sidebar";
import { silentCheckForUpdate } from "./hooks/useAppUpdater";
import { UpdateBanner } from "./components/UpdateBanner";
import { UpdaterProvider } from "./components/UpdaterProvider";
import useTauriEvent from "./hooks/useTauriEvent";
import { useToast } from "./hooks/useToast";
import { useI18n } from "./i18n";
Expand Down Expand Up @@ -92,14 +93,8 @@ function App() {
} | null>(null);
const previousContentTabRef = useRef<TabType>("configs");
const editorExitGuardRef = useRef<EditorExitGuard | null>(null);
const updateCheckedRef = useRef(false);
const historyProjectRequestIdRef = useRef(0);
const usageProjectRequestIdRef = useRef(0);
// 持有最新的 t / showToast,供异步回调读取,避免 promise resolve 前切换语言导致文案陈旧
const tRef = useRef(t);
tRef.current = t;
const showToastRef = useRef(showToast);
showToastRef.current = showToast;

const loadWorkspace = useCallback(async () => {
if (!isTauri()) {
Expand All @@ -123,24 +118,6 @@ function App() {
void loadWorkspace();
}, [loadWorkspace]);

// 启动时静默检查更新:发现新版仅 Toast 提示,由用户在设置中决定是否安装;失败静默。
// 只在挂载时执行一次;通过 tRef/showToastRef 读取最新值,规避语言切换后 Toast 文案陈旧。
useEffect(() => {
if (updateCheckedRef.current) return;
updateCheckedRef.current = true;
void silentCheckForUpdate().then((version) => {
if (version) {
showToastRef.current(
tRef.current("update.available").replace("{version}", version),
"success",
{
description: tRef.current("update.availableHint"),
},
);
}
});
}, []);

useEffect(() => {
document.documentElement.style.setProperty(
"--app-sidebar-width",
Expand Down Expand Up @@ -291,88 +268,95 @@ function App() {
}

return (
<TooltipProvider delayDuration={200}>
<div className="flex h-screen overflow-hidden bg-background text-foreground">
<Sidebar
activeTab={activeTab}
collapseSidebarByDefault={workspace.app.collapseSidebarByDefault}
onTabChange={(tab) => {
runWithEditorExitGuard(() => activateTab(tab));
}}
onClaudeOverviewClick={handleClaudeOverviewClick}
onSettingsClick={handleSettingsClick}
/>

<div className="relative flex flex-1 overflow-hidden">
{activeTab === "claudeOverview" || hasVisitedClaudeOverview ? (
<div
className={cn(
"absolute inset-0 min-w-0",
activeTab === "claudeOverview" ? "block" : "hidden",
)}
aria-hidden={activeTab !== "claudeOverview"}
>
<UpdaterProvider>
<TooltipProvider delayDuration={200}>
<div className="flex h-screen overflow-hidden bg-background text-foreground">
<Sidebar
activeTab={activeTab}
collapseSidebarByDefault={workspace.app.collapseSidebarByDefault}
onTabChange={(tab) => {
runWithEditorExitGuard(() => activateTab(tab));
}}
onClaudeOverviewClick={handleClaudeOverviewClick}
onSettingsClick={handleSettingsClick}
/>

<div className="flex flex-1 flex-col overflow-hidden">
<UpdateBanner />
<div className="relative flex flex-1 overflow-hidden">
{activeTab === "claudeOverview" || hasVisitedClaudeOverview ? (
<div
className={cn(
"absolute inset-0 min-w-0",
activeTab === "claudeOverview" ? "block" : "hidden",
)}
aria-hidden={activeTab !== "claudeOverview"}
>
<Suspense fallback={<PageLoadingFallback />}>
<ClaudeOverviewPage active={activeTab === "claudeOverview"} />
</Suspense>
</div>
) : null}
<Suspense fallback={<PageLoadingFallback />}>
<ClaudeOverviewPage active={activeTab === "claudeOverview"} />
</Suspense>
</div>
) : null}
<Suspense fallback={<PageLoadingFallback />}>
{activeTab === "claudeOverview" ? null : activeTab === "cheatsheet" ? (
<CheatSheetPage />
) : activeTab === "stats" ? (
<StatsPage />
) : activeTab === "usage" ? (
<UsagePage
projectRequest={usageProjectRequest}
onOpenSessionInHistory={handleOpenSessionInHistory}
/>
) : activeTab === "projects" ? (
<ProjectsPage
onOpenProjectHistory={handleOpenProjectHistory}
onOpenProjectUsage={handleOpenProjectUsage}
onOpenSessionInHistory={handleOpenSessionInHistory}
/>
) : activeTab === "history" ? (
<HistoryPage projectRequest={historyProjectRequest} />
) : activeTab === "configs" ? (
<ProfilesPage
workspace={workspace}
onWorkspaceChange={loadWorkspace}
onEditorExitGuardChange={setEditorExitGuard}
/>
) : (
<div
className={cn(
"flex shrink-0 flex-col overflow-y-auto overflow-x-hidden bg-secondary transition-[width] duration-300 ease-out scrollbar-none max-[1000px]:fixed max-[1000px]:inset-y-0 max-[1000px]:right-0 max-[1000px]:left-[60px] max-[1000px]:z-50 max-[1000px]:w-auto max-[700px]:left-[48px]",
isDetailDrawerOpen ? LIST_PANEL_COMPRESSED_WIDTH_CLASS : LIST_PANEL_WIDTH_CLASS,
)}
>
{activeTab === "memory" && (
<MemoryPage
onDrawerChange={setIsDetailDrawerOpen}
onEditorExitGuardChange={setEditorExitGuard}
{activeTab === "claudeOverview" ? null : activeTab === "cheatsheet" ? (
<CheatSheetPage />
) : activeTab === "stats" ? (
<StatsPage />
) : activeTab === "usage" ? (
<UsagePage
projectRequest={usageProjectRequest}
onOpenSessionInHistory={handleOpenSessionInHistory}
/>
)}
{activeTab === "skills" && (
<SkillsPage
onDrawerChange={setIsDetailDrawerOpen}
) : activeTab === "projects" ? (
<ProjectsPage
onOpenProjectHistory={handleOpenProjectHistory}
onOpenProjectUsage={handleOpenProjectUsage}
onOpenSessionInHistory={handleOpenSessionInHistory}
/>
) : activeTab === "history" ? (
<HistoryPage projectRequest={historyProjectRequest} />
) : activeTab === "configs" ? (
<ProfilesPage
workspace={workspace}
onWorkspaceChange={loadWorkspace}
onEditorExitGuardChange={setEditorExitGuard}
/>
) : (
<div
className={cn(
"flex shrink-0 flex-col overflow-y-auto overflow-x-hidden bg-secondary transition-[width] duration-300 ease-out scrollbar-none max-[1000px]:fixed max-[1000px]:inset-y-0 max-[1000px]:right-0 max-[1000px]:left-[60px] max-[1000px]:z-50 max-[1000px]:w-auto max-[700px]:left-[48px]",
isDetailDrawerOpen
? LIST_PANEL_COMPRESSED_WIDTH_CLASS
: LIST_PANEL_WIDTH_CLASS,
)}
>
{activeTab === "memory" && (
<MemoryPage
onDrawerChange={setIsDetailDrawerOpen}
onEditorExitGuardChange={setEditorExitGuard}
/>
)}
{activeTab === "skills" && (
<SkillsPage
onDrawerChange={setIsDetailDrawerOpen}
onEditorExitGuardChange={setEditorExitGuard}
/>
)}
</div>
)}
</div>
)}
</Suspense>
</div>
</Suspense>
</div>
</div>

{isSettingsOpen && (
<Suspense fallback={null}>
<SettingsDrawer onClose={closeSettingsDrawer} />
</Suspense>
)}
</div>
<Toaster richColors closeButton position="top-right" />
</TooltipProvider>
{isSettingsOpen && (
<Suspense fallback={null}>
<SettingsDrawer onClose={closeSettingsDrawer} />
</Suspense>
)}
</div>
<Toaster richColors closeButton position="top-right" />
</TooltipProvider>
</UpdaterProvider>
);
}

Expand Down
5 changes: 2 additions & 3 deletions src/components/SettingsDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
} from "lucide-react";
import { type ReactNode, useEffect, useMemo, useState } from "react";
import { showOperationError } from "@/lib/user-facing-error";
import { useAppUpdater } from "../hooks/useAppUpdater";
import { useToast } from "../hooks/useToast";
import { type Language, type TranslationKey, useI18n } from "../i18n";
import { ipc } from "../ipc";
Expand All @@ -44,6 +43,7 @@ import {
keyEventToAccelerator,
} from "./shortcut-utils";
import { type Theme, useTheme } from "./theme-provider";
import { useUpdater } from "./UpdaterProvider";
import { Button } from "./ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
import { Checkbox } from "./ui/checkbox";
Expand Down Expand Up @@ -294,8 +294,7 @@ function SettingsSectionCard({
// 应用更新设置卡片:展示当前版本,手动检查更新并下载安装(状态机见 useAppUpdater)
function UpdateSettingsCard() {
const { t } = useI18n();
const { status, availableVersion, progress, checkForUpdate, downloadAndRestart } =
useAppUpdater();
const { status, availableVersion, progress, checkForUpdate, downloadAndRestart } = useUpdater();
const [currentVersion, setCurrentVersion] = useState<string | null>(null);

useEffect(() => {
Expand Down
58 changes: 58 additions & 0 deletions src/components/UpdateBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Download, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useI18n } from "../i18n";
import { useUpdater } from "./UpdaterProvider";

/**
* 顶部常驻更新横幅:仅在发现新版本 / 下载中 / 待重启时渲染,否则隐藏。
* 整条承载「立即更新」动作,点击即走下载 → 进度 → 重启流程;克制细条,使用 shadcn 语义色。
*/
export function UpdateBanner() {
const { t } = useI18n();
const { status, availableVersion, progress, downloadAndRestart } = useUpdater();

// 只有这三种状态需要常驻提示;其余(idle/checking/upToDate/error)不打扰用户
if (status !== "available" && status !== "downloading" && status !== "ready") {
return null;
}

const isDownloading = status === "downloading";

let message: string;
let actionLabel: string;
if (status === "downloading") {
message = t("update.downloading").replace("{percent}", String(progress));
// 下载中横幅文案与按钮文案一致,复用同一份已算好的字符串,避免热路径重复计算
actionLabel = message;
} else if (status === "ready") {
message = t("update.readyBanner");
actionLabel = t("update.restartNow");
} else {
message = t("update.available").replace("{version}", availableVersion ?? "");
actionLabel = t("update.updateNow");
}

return (
<div
className="flex shrink-0 items-center gap-3 border-b border-primary/20 bg-primary/10 px-4 py-2 text-sm text-primary"
role="status"
>
{status === "ready" ? (
<RefreshCw className="size-4 shrink-0" aria-hidden="true" />
) : (
<Download className="size-4 shrink-0" aria-hidden="true" />
)}
<span className="min-w-0 flex-1 truncate font-medium">{message}</span>
<Button
type="button"
size="sm"
disabled={isDownloading}
onClick={() => {
void downloadAndRestart();
}}
>
{actionLabel}
</Button>
</div>
);
}
Loading