初始化web服务功能
This commit is contained in:
@@ -3,12 +3,12 @@
|
||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window"
|
||||
const getCurrentWindow = async () => { const m = await import("@tauri-apps/api/window"); return m.getCurrentWindow() }
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { CommitWorkspace } from "@/components/layout/commit-dialog"
|
||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||
import { AppToaster } from "@/components/ui/app-toaster"
|
||||
import { getFolder } from "@/lib/tauri"
|
||||
import { getFolder } from "@/lib/api"
|
||||
import type { FolderDetail } from "@/lib/types"
|
||||
|
||||
const TOAST_DURATION_MS = 6000
|
||||
@@ -35,12 +35,13 @@ function CommitPageInner() {
|
||||
const folder = state.loadedId === normalizedFolderId ? state.folder : null
|
||||
const error = state.loadedId === normalizedFolderId ? state.error : null
|
||||
|
||||
const closeWindow = useCallback(() => {
|
||||
getCurrentWindow()
|
||||
.close()
|
||||
.catch((err) => {
|
||||
console.error("[CommitPage] failed to close window:", err)
|
||||
})
|
||||
const closeWindow = useCallback(async () => {
|
||||
try {
|
||||
const win = await getCurrentWindow()
|
||||
await win.close()
|
||||
} catch (err) {
|
||||
console.error("[CommitPage] failed to close window:", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
91
src/app/login/page.tsx
Normal file
91
src/app/login/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { isDesktop } from "@/lib/platform"
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [token, setToken] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Desktop users skip login entirely
|
||||
if (isDesktop()) {
|
||||
router.replace("/welcome")
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// Validate token by calling a lightweight API endpoint
|
||||
const res = await fetch("/api/health", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: "{}",
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
localStorage.setItem("codeg_token", token)
|
||||
router.replace("/welcome")
|
||||
} else if (res.status === 401) {
|
||||
setError("Token 无效,请检查后重试")
|
||||
} else {
|
||||
setError(`连接失败 (HTTP ${res.status})`)
|
||||
}
|
||||
} catch {
|
||||
setError("无法连接到服务器")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="w-full max-w-sm space-y-6 px-4">
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Codeg</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
输入访问 Token 以连接到桌面端
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="Access Token"
|
||||
autoFocus
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!token || loading}
|
||||
className="inline-flex h-10 w-full items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground ring-offset-background transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{loading ? "连接中..." : "连接"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Token 可在桌面端 设置 → Web 服务 中获取
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,12 +3,12 @@
|
||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window"
|
||||
const getCurrentWindow = async () => { const m = await import("@tauri-apps/api/window"); return m.getCurrentWindow() }
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { MergeWorkspace } from "@/components/merge/merge-workspace"
|
||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||
import { AppToaster } from "@/components/ui/app-toaster"
|
||||
import { getFolder } from "@/lib/tauri"
|
||||
import { getFolder } from "@/lib/api"
|
||||
import type { FolderDetail } from "@/lib/types"
|
||||
|
||||
const TOAST_DURATION_MS = 6000
|
||||
@@ -37,12 +37,13 @@ function MergePageInner() {
|
||||
const folder = state.loadedId === normalizedFolderId ? state.folder : null
|
||||
const error = state.loadedId === normalizedFolderId ? state.error : null
|
||||
|
||||
const closeWindow = useCallback(() => {
|
||||
getCurrentWindow()
|
||||
.close()
|
||||
.catch((err) => {
|
||||
console.error("[MergePage] failed to close window:", err)
|
||||
})
|
||||
const closeWindow = useCallback(async () => {
|
||||
try {
|
||||
const win = await getCurrentWindow()
|
||||
await win.close()
|
||||
} catch (err) {
|
||||
console.error("[MergePage] failed to close window:", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,11 +2,43 @@
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { isDesktop } from "@/lib/platform"
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter()
|
||||
useEffect(() => {
|
||||
router.replace("/welcome")
|
||||
if (isDesktop()) {
|
||||
router.replace("/welcome")
|
||||
return
|
||||
}
|
||||
// Web mode: validate token before entering app
|
||||
const token = localStorage.getItem("codeg_token")
|
||||
if (!token) {
|
||||
router.replace("/login")
|
||||
return
|
||||
}
|
||||
// Verify token is still valid
|
||||
fetch("/api/health", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: "{}",
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.ok) {
|
||||
router.replace("/welcome")
|
||||
} else {
|
||||
localStorage.removeItem("codeg_token")
|
||||
router.replace("/login")
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Server unreachable
|
||||
localStorage.removeItem("codeg_token")
|
||||
router.replace("/login")
|
||||
})
|
||||
}, [router])
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { Suspense, useCallback, useEffect, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window"
|
||||
const getCurrentWindow = async () => { const m = await import("@tauri-apps/api/window"); return m.getCurrentWindow() }
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { PushWorkspace } from "@/components/layout/push-workspace"
|
||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||
import { AppToaster } from "@/components/ui/app-toaster"
|
||||
import { getFolder } from "@/lib/tauri"
|
||||
import { getFolder } from "@/lib/api"
|
||||
import type { FolderDetail } from "@/lib/types"
|
||||
|
||||
const TOAST_DURATION_MS = 6000
|
||||
@@ -28,12 +28,13 @@ function PushPageInner() {
|
||||
error: null,
|
||||
})
|
||||
|
||||
const closeWindow = useCallback(() => {
|
||||
getCurrentWindow()
|
||||
.close()
|
||||
.catch((err) => {
|
||||
console.error("[PushPage] failed to close window:", err)
|
||||
})
|
||||
const closeWindow = useCallback(async () => {
|
||||
try {
|
||||
const win = await getCurrentWindow()
|
||||
await win.close()
|
||||
} catch (err) {
|
||||
console.error("[PushPage] failed to close window:", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const folderId = Number(searchParams.get("folderId") ?? "0")
|
||||
|
||||
5
src/app/settings/web-service/page.tsx
Normal file
5
src/app/settings/web-service/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { WebServiceSettings } from "@/components/settings/web-service-settings"
|
||||
|
||||
export default function SettingsWebServicePage() {
|
||||
return <WebServiceSettings />
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { Loader2 } from "lucide-react"
|
||||
import { StashWorkspace } from "@/components/layout/unstash-dialog"
|
||||
import { AppTitleBar } from "@/components/layout/app-title-bar"
|
||||
import { AppToaster } from "@/components/ui/app-toaster"
|
||||
import { getFolder } from "@/lib/tauri"
|
||||
import { getFolder } from "@/lib/api"
|
||||
import type { FolderDetail } from "@/lib/types"
|
||||
|
||||
const TOAST_DURATION_MS = 6000
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { openUrl } from "@tauri-apps/plugin-opener"
|
||||
import { openUrl } from "@/lib/platform"
|
||||
import type { LinkSafetyConfig, LinkSafetyModalProps } from "streamdown"
|
||||
import { toast } from "sonner"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { acpListAgents } from "@/lib/tauri"
|
||||
import { acpListAgents } from "@/lib/api"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import type { AgentType, AcpAgentInfo } from "@/lib/types"
|
||||
import { AGENT_LABELS } from "@/lib/types"
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { TauriEvent } from "@tauri-apps/api/event"
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview"
|
||||
import { open } from "@tauri-apps/plugin-dialog"
|
||||
import { isDesktop } from "@/lib/platform"
|
||||
import Image from "next/image"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -34,7 +32,8 @@ import {
|
||||
import { cn } from "@/lib/utils"
|
||||
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
|
||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||
import { readFileBase64 } from "@/lib/tauri"
|
||||
import { readFileBase64 } from "@/lib/api"
|
||||
import { openFileDialog } from "@/lib/platform"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import type {
|
||||
AvailableCommandInfo,
|
||||
@@ -751,10 +750,9 @@ export function MessageInput({
|
||||
const handlePickFiles = useCallback(async () => {
|
||||
if (disabled) return
|
||||
try {
|
||||
const selected = await open({
|
||||
const selected = await openFileDialog({
|
||||
multiple: true,
|
||||
directory: false,
|
||||
defaultPath: defaultPath || undefined,
|
||||
})
|
||||
if (!selected) return
|
||||
const picked = Array.isArray(selected) ? selected : [selected]
|
||||
@@ -846,6 +844,9 @@ export function MessageInput({
|
||||
}
|
||||
|
||||
const setup = async () => {
|
||||
if (!isDesktop()) return
|
||||
const { getCurrentWebview } = await import("@tauri-apps/api/webview")
|
||||
const { TauriEvent } = await import("@tauri-apps/api/event")
|
||||
const webview = getCurrentWebview()
|
||||
try {
|
||||
const unlistenEnter = await webview.listen<{
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
updateConversationExternalId,
|
||||
updateConversationStatus,
|
||||
updateConversationTitle,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
import { useConversationRuntime } from "@/contexts/conversation-runtime-context"
|
||||
import { useConversationDetail } from "@/hooks/use-conversation-detail"
|
||||
import {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
getFileTree,
|
||||
listFolderConversations,
|
||||
readFilePreview,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
import type {
|
||||
AgentType,
|
||||
ConversationStatus,
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
updateConversationTitle,
|
||||
updateConversationStatus,
|
||||
deleteConversation,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
import type { ConversationStatus, DbConversationSummary } from "@/lib/types"
|
||||
import { STATUS_ORDER, STATUS_COLORS } from "@/lib/types"
|
||||
import { SidebarConversationCard } from "./sidebar-conversation-card"
|
||||
|
||||
@@ -21,7 +21,7 @@ import { code } from "@streamdown/code"
|
||||
import { math } from "@streamdown/math"
|
||||
import { mermaid } from "@streamdown/mermaid"
|
||||
import { Streamdown } from "streamdown"
|
||||
import { readFileBase64 } from "@/lib/tauri"
|
||||
import { readFileBase64 } from "@/lib/api"
|
||||
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
|
||||
import "@/lib/monaco-local"
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Reorder } from "motion/react"
|
||||
import { Code, Eye, ExternalLink, FileText, GitCompare, X } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { openPath } from "@tauri-apps/plugin-opener"
|
||||
import { openPath } from "@/lib/platform"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
toIntlLocale,
|
||||
type IntlLocale,
|
||||
} from "@/lib/i18n"
|
||||
import { getSystemLanguageSettings } from "@/lib/tauri"
|
||||
import { getSystemLanguageSettings } from "@/lib/api"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import { AppBootLoading } from "@/components/layout/app-boot-loading"
|
||||
import type { AppLocale, SystemLanguageSettings } from "@/lib/types"
|
||||
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
|
||||
import { revealItemInDir } from "@tauri-apps/plugin-opener"
|
||||
import { revealItemInDir, subscribe } from "@/lib/platform"
|
||||
import ignore from "ignore"
|
||||
import { Check, ChevronRight } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
@@ -36,8 +35,7 @@ import {
|
||||
saveFileCopy,
|
||||
startFileTreeWatch,
|
||||
stopFileTreeWatch,
|
||||
} from "@/lib/tauri"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
} from "@/lib/api"
|
||||
import {
|
||||
emitAttachFileToSession,
|
||||
emitAppendTextToSession,
|
||||
@@ -1907,7 +1905,7 @@ export function FileTreeTab() {
|
||||
const rootPath = folder?.path
|
||||
if (!rootPath) return
|
||||
|
||||
let unlisten: UnlistenFn | null = null
|
||||
let unlisten: (() => void) | null = null
|
||||
const normalizedRootPath = normalizeComparePath(rootPath)
|
||||
|
||||
const scheduleTreeRefresh = (refreshGitStatus: boolean) => {
|
||||
@@ -2046,20 +2044,17 @@ export function FileTreeTab() {
|
||||
}
|
||||
|
||||
try {
|
||||
unlisten = await listen<FileTreeChangedEvent>(
|
||||
unlisten = await subscribe<FileTreeChangedEvent>(
|
||||
"folder://file-tree-changed",
|
||||
(event) => {
|
||||
(payload) => {
|
||||
if (
|
||||
normalizeComparePath(event.payload.root_path) !==
|
||||
normalizedRootPath
|
||||
normalizeComparePath(payload.root_path) !== normalizedRootPath
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const changedPaths =
|
||||
event.payload.changed_paths.map(normalizeComparePath)
|
||||
const shouldRefreshGitStatus =
|
||||
event.payload.refresh_git_status ?? true
|
||||
const changedPaths = payload.changed_paths.map(normalizeComparePath)
|
||||
const shouldRefreshGitStatus = payload.refresh_git_status ?? true
|
||||
const nonGitChangedPaths = changedPaths.filter(
|
||||
(path) => !isGitMetadataPath(path)
|
||||
)
|
||||
@@ -2069,13 +2064,13 @@ export function FileTreeTab() {
|
||||
(path) => !filePathSetRef.current.has(path)
|
||||
)
|
||||
const needsTreeRefresh =
|
||||
event.payload.full_reload ||
|
||||
payload.full_reload ||
|
||||
(!onlyGitMetadataChanges &&
|
||||
(event.payload.kind !== "modify" ||
|
||||
(payload.kind !== "modify" ||
|
||||
nonGitChangedPaths.length === 0 ||
|
||||
hasUnknownPath))
|
||||
|
||||
if (onlyGitMetadataChanges && !event.payload.full_reload) {
|
||||
if (onlyGitMetadataChanges && !payload.full_reload) {
|
||||
if (shouldRefreshGitStatus) {
|
||||
scheduleStatusRefresh()
|
||||
}
|
||||
@@ -2085,13 +2080,13 @@ export function FileTreeTab() {
|
||||
scheduleStatusRefresh()
|
||||
}
|
||||
|
||||
if (onlyGitMetadataChanges && !event.payload.full_reload) {
|
||||
if (onlyGitMetadataChanges && !payload.full_reload) {
|
||||
return
|
||||
}
|
||||
|
||||
const changedActivePath = getActiveChangedFilePath(
|
||||
nonGitChangedPaths,
|
||||
event.payload.full_reload
|
||||
payload.full_reload
|
||||
)
|
||||
if (!changedActivePath) return
|
||||
|
||||
@@ -2145,7 +2140,7 @@ export function FileTreeTab() {
|
||||
pendingTreeRefreshRef.current = false
|
||||
pendingTreeRefreshNeedsStatusRef.current = false
|
||||
pendingStatusRefreshRef.current = false
|
||||
disposeTauriListener(unlisten, "AuxPanelFileTree.fileTreeChanged")
|
||||
unlisten?.()
|
||||
void stopFileTreeWatch(rootPath)
|
||||
}
|
||||
}, [fetchTree, folder?.path, openFilePreview, t])
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
|
||||
import { subscribe } from "@/lib/platform"
|
||||
import { ChevronsDownUp, ChevronsUpDown } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
@@ -46,8 +46,7 @@ import {
|
||||
openCommitWindow,
|
||||
startFileTreeWatch,
|
||||
stopFileTreeWatch,
|
||||
} from "@/lib/tauri"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
} from "@/lib/api"
|
||||
import type { FileTreeChangedEvent, GitStatusEntry } from "@/lib/types"
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -611,7 +610,7 @@ export function GitChangesTab() {
|
||||
const rootPath = folder?.path
|
||||
if (!rootPath || !isChangesTabActive) return
|
||||
|
||||
let unlisten: UnlistenFn | null = null
|
||||
let unlisten: (() => void) | null = null
|
||||
const normalizedRootPath = normalizeComparePath(rootPath)
|
||||
|
||||
const scheduleRefresh = () => {
|
||||
@@ -631,16 +630,15 @@ export function GitChangesTab() {
|
||||
}
|
||||
|
||||
try {
|
||||
unlisten = await listen<FileTreeChangedEvent>(
|
||||
unlisten = await subscribe<FileTreeChangedEvent>(
|
||||
"folder://file-tree-changed",
|
||||
(event) => {
|
||||
(payload) => {
|
||||
if (
|
||||
normalizeComparePath(event.payload.root_path) !==
|
||||
normalizedRootPath
|
||||
normalizeComparePath(payload.root_path) !== normalizedRootPath
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (!shouldRefreshFromEvent(event.payload)) return
|
||||
if (!shouldRefreshFromEvent(payload)) return
|
||||
scheduleRefresh()
|
||||
}
|
||||
)
|
||||
@@ -656,7 +654,7 @@ export function GitChangesTab() {
|
||||
clearTimeout(refreshTimerRef.current)
|
||||
refreshTimerRef.current = null
|
||||
}
|
||||
disposeTauriListener(unlisten, "AuxPanelGitChanges.fileTreeChanged")
|
||||
unlisten?.()
|
||||
void stopFileTreeWatch(rootPath)
|
||||
}
|
||||
}, [fetchChanges, folder?.path, isChangesTabActive])
|
||||
|
||||
@@ -75,8 +75,7 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import { subscribe } from "@/lib/platform"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
import {
|
||||
@@ -86,7 +85,7 @@ import {
|
||||
gitLog,
|
||||
gitNewBranch,
|
||||
openPushWindow,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
import type { GitBranchList, GitLogEntry, GitLogFileChange } from "@/lib/types"
|
||||
import { toast } from "sonner"
|
||||
import { toErrorMessage } from "@/lib/app-error"
|
||||
@@ -874,11 +873,11 @@ export function GitLogTab() {
|
||||
"folder://git-push-succeeded",
|
||||
] as const
|
||||
|
||||
const unlistens: (UnlistenFn | null)[] = events.map(() => null)
|
||||
const unlistens: ((() => void) | null)[] = events.map(() => null)
|
||||
|
||||
events.forEach((eventName, i) => {
|
||||
listen<{ folder_id: number }>(eventName, (event) => {
|
||||
if (event.payload.folder_id !== folder.id) return
|
||||
subscribe<{ folder_id: number }>(eventName, (payload) => {
|
||||
if (payload.folder_id !== folder.id) return
|
||||
void refreshBranches()
|
||||
void fetchLog({ inline: true })
|
||||
})
|
||||
@@ -891,8 +890,8 @@ export function GitLogTab() {
|
||||
})
|
||||
|
||||
return () => {
|
||||
events.forEach((eventName, i) => {
|
||||
disposeTauriListener(unlistens[i], `GitLogTab.${eventName}`)
|
||||
events.forEach((_eventName, i) => {
|
||||
unlistens[i]?.()
|
||||
})
|
||||
}
|
||||
}, [folder, refreshBranches, fetchLog])
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useCallback, useMemo, useEffect } from "react"
|
||||
import { emit, listen, type UnlistenFn } from "@tauri-apps/api/event"
|
||||
const emitEvent = async (event: string, payload?: unknown) => {
|
||||
try {
|
||||
const { emit } = await import("@tauri-apps/api/event")
|
||||
await emit(event, payload)
|
||||
} catch { /* not in Tauri */ }
|
||||
}
|
||||
import { openFileDialog, subscribe } from "@/lib/platform"
|
||||
import {
|
||||
GitBranch,
|
||||
ChevronDown,
|
||||
@@ -62,7 +68,6 @@ import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { open } from "@tauri-apps/plugin-dialog"
|
||||
import {
|
||||
gitInit,
|
||||
gitPull,
|
||||
@@ -79,11 +84,10 @@ import {
|
||||
setFolderParentBranch,
|
||||
openStashWindow,
|
||||
openPushWindow,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog"
|
||||
import { ConflictDialog } from "@/components/layout/conflict-dialog"
|
||||
import { StashDialog } from "@/components/layout/stash-dialog"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import { toErrorMessage } from "@/lib/app-error"
|
||||
import type { GitBranchList, GitConflictInfo } from "@/lib/types"
|
||||
import { toast } from "sonner"
|
||||
@@ -167,15 +171,15 @@ export function BranchDropdown({
|
||||
useEffect(() => {
|
||||
if (!folder) return
|
||||
|
||||
let unlisten: UnlistenFn | null = null
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
listen<GitCommitSucceededEventPayload>(
|
||||
subscribe<GitCommitSucceededEventPayload>(
|
||||
"folder://git-commit-succeeded",
|
||||
(event) => {
|
||||
if (event.payload.folder_id !== folder.id) return
|
||||
(payload) => {
|
||||
if (payload.folder_id !== folder.id) return
|
||||
toast.success(t("toasts.commitCodeCompleted"), {
|
||||
description: t("toasts.committedFiles", {
|
||||
count: event.payload.committed_files,
|
||||
count: payload.committed_files,
|
||||
}),
|
||||
})
|
||||
onBranchChange()
|
||||
@@ -189,20 +193,20 @@ export function BranchDropdown({
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposeTauriListener(unlisten, "BranchDropdown.gitCommitSucceeded")
|
||||
unlisten?.()
|
||||
}
|
||||
}, [folder, onBranchChange, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (!folder) return
|
||||
|
||||
let unlisten: UnlistenFn | null = null
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
listen<GitPushSucceededEventPayload>(
|
||||
subscribe<GitPushSucceededEventPayload>(
|
||||
"folder://git-push-succeeded",
|
||||
(event) => {
|
||||
if (event.payload.folder_id !== folder.id) return
|
||||
const { pushed_commits, upstream_set } = event.payload
|
||||
(payload) => {
|
||||
if (payload.folder_id !== folder.id) return
|
||||
const { pushed_commits, upstream_set } = payload
|
||||
let description: string
|
||||
if (upstream_set) {
|
||||
description =
|
||||
@@ -226,7 +230,7 @@ export function BranchDropdown({
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposeTauriListener(unlisten, "BranchDropdown.gitPushSucceeded")
|
||||
unlisten?.()
|
||||
}
|
||||
}, [folder, onBranchChange, t])
|
||||
|
||||
@@ -245,7 +249,7 @@ export function BranchDropdown({
|
||||
const successDescription = getSuccessDescription?.(result)
|
||||
updateTask(taskId, { status: "completed" })
|
||||
onBranchChange()
|
||||
void emit("folder://git-branch-changed", {
|
||||
void emitEvent("folder://git-branch-changed", {
|
||||
folder_id: folder?.id,
|
||||
})
|
||||
if (successDescription !== false) {
|
||||
@@ -326,9 +330,11 @@ export function BranchDropdown({
|
||||
}
|
||||
|
||||
async function handleBrowseWorktreePath() {
|
||||
const selected = await open({ directory: true, multiple: false })
|
||||
const selected = await openFileDialog({ directory: true, multiple: false })
|
||||
if (selected) {
|
||||
setWorktreePath(selected)
|
||||
setWorktreePath(
|
||||
Array.isArray(selected) ? selected[0] : selected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
|
||||
import { subscribe } from "@/lib/platform"
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react"
|
||||
import { ChevronDown, Play, Plus, Square } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
@@ -19,8 +19,7 @@ import {
|
||||
listFolderCommands,
|
||||
terminalKill,
|
||||
terminalList,
|
||||
} from "@/lib/tauri"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
} from "@/lib/api"
|
||||
import type { FolderCommand, TerminalEvent } from "@/lib/types"
|
||||
import { CommandManageDialog } from "./command-manage-dialog"
|
||||
|
||||
@@ -54,7 +53,7 @@ export function CommandDropdown() {
|
||||
const [runningCommandTerminals, setRunningCommandTerminals] = useState<
|
||||
Record<number, string>
|
||||
>({})
|
||||
const exitUnlistenersRef = useRef<Map<string, UnlistenFn>>(new Map())
|
||||
const exitUnlistenersRef = useRef<Map<string, () => void>>(new Map())
|
||||
const runningCommandTerminalsRef = useRef<Record<number, string>>({})
|
||||
|
||||
const folderId = folder?.id ?? 0
|
||||
@@ -67,7 +66,7 @@ export function CommandDropdown() {
|
||||
const clearRunningByTerminalId = useCallback((terminalId: string) => {
|
||||
const unlisten = exitUnlistenersRef.current.get(terminalId)
|
||||
if (unlisten) {
|
||||
disposeTauriListener(unlisten, "CommandDropdown.terminalExit")
|
||||
unlisten()
|
||||
exitUnlistenersRef.current.delete(terminalId)
|
||||
}
|
||||
|
||||
@@ -86,7 +85,7 @@ export function CommandDropdown() {
|
||||
|
||||
const clearAllRunningStates = useCallback(() => {
|
||||
for (const unlisten of exitUnlistenersRef.current.values()) {
|
||||
disposeTauriListener(unlisten, "CommandDropdown.terminalExit")
|
||||
unlisten()
|
||||
}
|
||||
exitUnlistenersRef.current.clear()
|
||||
setRunningCommandTerminals({})
|
||||
@@ -165,7 +164,7 @@ export function CommandDropdown() {
|
||||
async (terminalId: string) => {
|
||||
if (exitUnlistenersRef.current.has(terminalId)) return
|
||||
try {
|
||||
const unlisten = await listen<TerminalEvent>(
|
||||
const unlisten = await subscribe<TerminalEvent>(
|
||||
`terminal://exit/${terminalId}`,
|
||||
() => {
|
||||
clearRunningByTerminalId(terminalId)
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
createFolderCommand,
|
||||
updateFolderCommand,
|
||||
deleteFolderCommand,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
|
||||
interface CommandDraft {
|
||||
id: number | null
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
gitStatus,
|
||||
deleteFileTreeEntry,
|
||||
readFilePreview,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
import type { GitStatusEntry } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
|
||||
import { subscribe } from "@/lib/platform"
|
||||
import { AlertTriangle, Check, FileWarning, Loader2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
@@ -20,8 +20,7 @@ import {
|
||||
gitAbortOperation,
|
||||
gitContinueOperation,
|
||||
openMergeWindow,
|
||||
} from "@/lib/tauri"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
} from "@/lib/api"
|
||||
import type { GitConflictInfo } from "@/lib/types"
|
||||
|
||||
interface ConflictDialogProps {
|
||||
@@ -76,15 +75,15 @@ export function ConflictDialog({
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
let unlistenResolved: UnlistenFn | null = null
|
||||
let unlistenCompleted: UnlistenFn | null = null
|
||||
let unlistenAborted: UnlistenFn | null = null
|
||||
let unlistenResolved: (() => void) | null = null
|
||||
let unlistenCompleted: (() => void) | null = null
|
||||
let unlistenAborted: (() => void) | null = null
|
||||
|
||||
listen<{ folder_id: number; file: string }>(
|
||||
subscribe<{ folder_id: number; file: string }>(
|
||||
"folder://merge-conflict-resolved",
|
||||
(event) => {
|
||||
if (event.payload.folder_id !== folderId) return
|
||||
setResolvedFiles((prev) => new Set([...prev, event.payload.file]))
|
||||
(payload) => {
|
||||
if (payload.folder_id !== folderId) return
|
||||
setResolvedFiles((prev) => new Set([...prev, payload.file]))
|
||||
}
|
||||
)
|
||||
.then((fn) => {
|
||||
@@ -92,8 +91,8 @@ export function ConflictDialog({
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
listen<{ folder_id: number }>("folder://merge-completed", (event) => {
|
||||
if (event.payload.folder_id !== folderId) return
|
||||
subscribe<{ folder_id: number }>("folder://merge-completed", (payload) => {
|
||||
if (payload.folder_id !== folderId) return
|
||||
setDone(true)
|
||||
onResolved()
|
||||
onClose()
|
||||
@@ -105,8 +104,8 @@ export function ConflictDialog({
|
||||
|
||||
// Merge was aborted (user clicked abort in merge window, or window closed)
|
||||
// Reset resolved state since abort reverts all changes
|
||||
listen<{ folder_id: number }>("folder://merge-aborted", (event) => {
|
||||
if (event.payload.folder_id !== folderId) return
|
||||
subscribe<{ folder_id: number }>("folder://merge-aborted", (payload) => {
|
||||
if (payload.folder_id !== folderId) return
|
||||
setDone(true)
|
||||
setResolvedFiles(new Set())
|
||||
onClose()
|
||||
@@ -117,12 +116,9 @@ export function ConflictDialog({
|
||||
.catch(() => {})
|
||||
|
||||
return () => {
|
||||
disposeTauriListener(
|
||||
unlistenResolved,
|
||||
"ConflictDialog.mergeConflictResolved"
|
||||
)
|
||||
disposeTauriListener(unlistenCompleted, "ConflictDialog.mergeCompleted")
|
||||
disposeTauriListener(unlistenAborted, "ConflictDialog.mergeAborted")
|
||||
unlistenResolved?.()
|
||||
unlistenCompleted?.()
|
||||
unlistenAborted?.()
|
||||
}
|
||||
}, [open, folderId, onResolved, onClose])
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronDown, Folder, FolderOpen, GitBranch } from "lucide-react"
|
||||
import { open } from "@tauri-apps/plugin-dialog"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -17,7 +16,8 @@ import {
|
||||
listOpenFolders,
|
||||
loadFolderHistory,
|
||||
openFolderWindow,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
import { openFileDialog } from "@/lib/platform"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { CloneDialog } from "@/components/welcome/clone-dialog"
|
||||
import type { FolderHistoryEntry } from "@/lib/types"
|
||||
@@ -50,9 +50,11 @@ export function FolderNameDropdown() {
|
||||
}
|
||||
|
||||
async function handleOpenFolder() {
|
||||
const selected = await open({ directory: true, multiple: false })
|
||||
const selected = await openFileDialog({ directory: true, multiple: false })
|
||||
if (selected) {
|
||||
await openFolderWindow(selected)
|
||||
await openFolderWindow(
|
||||
Array.isArray(selected) ? selected[0] : selected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
useState,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
} from "react"
|
||||
import { open } from "@tauri-apps/plugin-dialog"
|
||||
import {
|
||||
Columns2,
|
||||
FileCode2,
|
||||
@@ -19,7 +18,8 @@ import {
|
||||
Settings,
|
||||
} from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { getGitBranch, openFolderWindow, openSettingsWindow } from "@/lib/tauri"
|
||||
import { getGitBranch, openFolderWindow, openSettingsWindow } from "@/lib/api"
|
||||
import { openFileDialog } from "@/lib/platform"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useSidebarContext } from "@/contexts/sidebar-context"
|
||||
@@ -79,8 +79,9 @@ export function FolderTitleBar() {
|
||||
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
try {
|
||||
const selected = await open({ directory: true, multiple: false })
|
||||
if (!selected) return
|
||||
const result = await openFileDialog({ directory: true, multiple: false })
|
||||
if (!result) return
|
||||
const selected = Array.isArray(result) ? result[0] : result
|
||||
await openFolderWindow(selected)
|
||||
} catch (err) {
|
||||
console.error("[FolderTitleBar] failed to open folder:", err)
|
||||
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { gitLog, gitPush, gitPushInfo, gitShowFile } from "@/lib/tauri"
|
||||
import { gitLog, gitPush, gitPushInfo, gitShowFile } from "@/lib/api"
|
||||
import { toErrorMessage } from "@/lib/app-error"
|
||||
import { languageFromPath } from "@/lib/language-detect"
|
||||
import type { GitLogEntry, GitLogFileChange, GitPushInfo } from "@/lib/types"
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
gitAddRemote,
|
||||
gitRemoveRemote,
|
||||
gitSetRemoteUrl,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
|
||||
interface RemoteDraft {
|
||||
originalName: string | null
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { gitStashPush } from "@/lib/tauri"
|
||||
import { gitStashPush } from "@/lib/api"
|
||||
import { toErrorMessage } from "@/lib/app-error"
|
||||
|
||||
interface StashDialogProps {
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { useAcpActions } from "@/contexts/acp-connections-context"
|
||||
import { openUrl } from "@tauri-apps/plugin-opener"
|
||||
import { openSettingsWindow } from "@/lib/tauri"
|
||||
import { openUrl } from "@/lib/platform"
|
||||
import { openSettingsWindow } from "@/lib/api"
|
||||
import { AGENT_LABELS, type AgentType } from "@/lib/types"
|
||||
|
||||
const KNOWN_AGENT_TYPES = new Set<AgentType>(
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
gitStashApply,
|
||||
gitStashDrop,
|
||||
gitShowFile,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
import { toErrorMessage } from "@/lib/app-error"
|
||||
import { languageFromPath } from "@/lib/language-detect"
|
||||
import type { GitStashEntry, GitStatusEntry } from "@/lib/types"
|
||||
|
||||
@@ -1,72 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { isDesktop } from "@/lib/platform"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { usePlatform } from "@/hooks/use-platform"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
async function getTauriWindow() {
|
||||
const { getCurrentWindow } = await import("@tauri-apps/api/window")
|
||||
return getCurrentWindow()
|
||||
}
|
||||
|
||||
export function WindowControls() {
|
||||
const t = useTranslations("Folder.windowControls")
|
||||
const { isWindows } = usePlatform()
|
||||
const [isMaximized, setIsMaximized] = useState(false)
|
||||
const appWindowRef = useRef<Awaited<
|
||||
ReturnType<typeof getTauriWindow>
|
||||
> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWindows) return
|
||||
if (!isWindows || !isDesktop()) return
|
||||
|
||||
let disposed = false
|
||||
let unlistenResize: (() => void) | null = null
|
||||
let resizeFrame: number | null = null
|
||||
const appWindow = getCurrentWindow()
|
||||
|
||||
const syncMaximized = async () => {
|
||||
try {
|
||||
const maximized = await appWindow.isMaximized()
|
||||
if (!disposed) {
|
||||
setIsMaximized(maximized)
|
||||
}
|
||||
} catch {
|
||||
if (!disposed) {
|
||||
setIsMaximized(false)
|
||||
getTauriWindow().then((appWindow) => {
|
||||
if (disposed) return
|
||||
appWindowRef.current = appWindow
|
||||
|
||||
const syncMaximized = async () => {
|
||||
try {
|
||||
const maximized = await appWindow.isMaximized()
|
||||
if (!disposed) setIsMaximized(maximized)
|
||||
} catch {
|
||||
if (!disposed) setIsMaximized(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleSync = () => {
|
||||
if (resizeFrame !== null) return
|
||||
const scheduleSync = () => {
|
||||
if (resizeFrame !== null) return
|
||||
resizeFrame = window.requestAnimationFrame(() => {
|
||||
resizeFrame = null
|
||||
void syncMaximized()
|
||||
})
|
||||
}
|
||||
|
||||
resizeFrame = window.requestAnimationFrame(() => {
|
||||
resizeFrame = null
|
||||
void syncMaximized()
|
||||
})
|
||||
}
|
||||
void syncMaximized()
|
||||
|
||||
void syncMaximized()
|
||||
|
||||
appWindow
|
||||
.onResized(() => {
|
||||
scheduleSync()
|
||||
})
|
||||
.then((unlisten) => {
|
||||
unlistenResize = unlisten
|
||||
})
|
||||
.catch(() => {
|
||||
unlistenResize = null
|
||||
})
|
||||
appWindow
|
||||
.onResized(() => scheduleSync())
|
||||
.then((unlisten) => {
|
||||
unlistenResize = unlisten
|
||||
})
|
||||
.catch(() => {
|
||||
unlistenResize = null
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (resizeFrame !== null) {
|
||||
window.cancelAnimationFrame(resizeFrame)
|
||||
}
|
||||
disposeTauriListener(unlistenResize, "WindowControls.resize")
|
||||
unlistenResize?.()
|
||||
}
|
||||
}, [isWindows])
|
||||
|
||||
if (!isWindows) return null
|
||||
|
||||
const appWindow = getCurrentWindow()
|
||||
if (!isWindows || !isDesktop()) return null
|
||||
|
||||
return (
|
||||
<div className="flex h-8 items-stretch [-webkit-app-region:no-drag]">
|
||||
@@ -74,7 +76,7 @@ export function WindowControls() {
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
onClick={() => {
|
||||
appWindow.minimize().catch((err) => {
|
||||
appWindowRef.current?.minimize().catch((err: unknown) => {
|
||||
console.error("[WindowControls] failed to minimize:", err)
|
||||
})
|
||||
}}
|
||||
@@ -87,7 +89,7 @@ export function WindowControls() {
|
||||
type="button"
|
||||
className={buttonClass}
|
||||
onClick={() => {
|
||||
appWindow.toggleMaximize().catch((err) => {
|
||||
appWindowRef.current?.toggleMaximize().catch((err: unknown) => {
|
||||
console.error("[WindowControls] failed to toggle maximize:", err)
|
||||
})
|
||||
}}
|
||||
@@ -103,7 +105,7 @@ export function WindowControls() {
|
||||
"hover:bg-[#e81123] hover:text-white active:bg-[#c50f1f] active:text-white"
|
||||
)}
|
||||
onClick={() => {
|
||||
appWindow.close().catch((err) => {
|
||||
appWindowRef.current?.close().catch((err: unknown) => {
|
||||
console.error("[WindowControls] failed to close:", err)
|
||||
})
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { emit } from "@tauri-apps/api/event"
|
||||
async function emitEvent(event: string, payload?: unknown) {
|
||||
try {
|
||||
const { emit } = await import("@tauri-apps/api/event")
|
||||
await emit(event, payload)
|
||||
} catch { /* not in Tauri */ }
|
||||
}
|
||||
import { Check, FileWarning, Loader2, X, CheckCheck } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
@@ -19,7 +24,7 @@ import {
|
||||
gitAbortOperation,
|
||||
gitContinueOperation,
|
||||
gitStartPullMerge,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
import { languageFromPath } from "@/lib/language-detect"
|
||||
import { toErrorMessage } from "@/lib/app-error"
|
||||
import type { GitConflictFileVersions } from "@/lib/types"
|
||||
@@ -121,7 +126,7 @@ export function MergeWorkspace({
|
||||
setResolvedFiles((prev) => new Set([...prev, selectedFile]))
|
||||
|
||||
// Notify parent window
|
||||
await emit("folder://merge-conflict-resolved", {
|
||||
await emitEvent("folder://merge-conflict-resolved", {
|
||||
folder_id: folderId,
|
||||
file: selectedFile,
|
||||
})
|
||||
@@ -145,7 +150,7 @@ export function MergeWorkspace({
|
||||
try {
|
||||
await gitAbortOperation(folderPath, operation)
|
||||
toast.success(t("abortSuccess"))
|
||||
await emit("folder://merge-aborted", { folder_id: folderId })
|
||||
await emitEvent("folder://merge-aborted", { folder_id: folderId })
|
||||
onAborted()
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
@@ -159,7 +164,7 @@ export function MergeWorkspace({
|
||||
try {
|
||||
await gitContinueOperation(folderPath, operation)
|
||||
toast.success(t("allResolved"))
|
||||
await emit("folder://merge-completed", { folder_id: folderId })
|
||||
await emitEvent("folder://merge-completed", { folder_id: folderId })
|
||||
onCompleted()
|
||||
} catch (err) {
|
||||
toast.error(toErrorMessage(err))
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
Trash2,
|
||||
Wrench,
|
||||
} from "lucide-react"
|
||||
import { openUrl } from "@tauri-apps/plugin-opener"
|
||||
import { openUrl } from "@/lib/platform"
|
||||
import { toast } from "sonner"
|
||||
import { AgentIcon } from "@/components/agent-icon"
|
||||
import {
|
||||
@@ -64,7 +64,7 @@ import {
|
||||
acpReorderAgents,
|
||||
acpUninstallAgent,
|
||||
acpUpdateAgentPreferences,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
import type {
|
||||
AcpAgentInfo,
|
||||
AgentType,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { saveAccountToken } from "@/lib/tauri"
|
||||
import { saveAccountToken } from "@/lib/api"
|
||||
import type { GitHubAccount } from "@/lib/types"
|
||||
|
||||
interface AddGitAccountDialogProps {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
import { ExternalLink, Eye, EyeOff, Loader2 } from "lucide-react"
|
||||
import { openUrl } from "@tauri-apps/plugin-opener"
|
||||
import { openUrl } from "@/lib/platform"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { validateGitHubToken, saveAccountToken } from "@/lib/tauri"
|
||||
import { validateGitHubToken, saveAccountToken } from "@/lib/api"
|
||||
import type { GitHubAccount } from "@/lib/types"
|
||||
|
||||
interface AddGitHubAccountDialogProps {
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
mcpScanLocal,
|
||||
mcpSearchMarketplace,
|
||||
mcpUpsertLocalServer,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type {
|
||||
LocalMcpServer,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Bot,
|
||||
BookOpenText,
|
||||
GitBranch,
|
||||
Globe,
|
||||
Keyboard,
|
||||
Palette,
|
||||
PlugZap,
|
||||
@@ -28,6 +29,7 @@ interface SettingsNavItem {
|
||||
| "shortcuts"
|
||||
| "version_control"
|
||||
| "system"
|
||||
| "web_service"
|
||||
icon: ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
@@ -67,6 +69,11 @@ const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
|
||||
labelKey: "system",
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
href: "/settings/web-service",
|
||||
labelKey: "web_service",
|
||||
icon: Globe,
|
||||
},
|
||||
]
|
||||
|
||||
interface SettingsShellProps {
|
||||
|
||||
@@ -55,7 +55,7 @@ import {
|
||||
openFolderWindow,
|
||||
acpReadAgentSkill,
|
||||
acpSaveAgentSkill,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
import type {
|
||||
AcpAgentInfo,
|
||||
AgentSkillItem,
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
RefreshCw,
|
||||
Wifi,
|
||||
} from "lucide-react"
|
||||
import type { Update } from "@tauri-apps/plugin-updater"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Update = any
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { useAppI18n } from "@/components/i18n-provider"
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
getSystemProxySettings,
|
||||
updateSystemLanguageSettings,
|
||||
updateSystemProxySettings,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
import type { AppLocale } from "@/lib/types"
|
||||
import {
|
||||
checkAppUpdate,
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
validateGitHubToken,
|
||||
getAccountToken,
|
||||
deleteAccountToken,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
import type {
|
||||
GitDetectResult,
|
||||
GitHubAccount,
|
||||
|
||||
179
src/components/settings/web-service-settings.tsx
Normal file
179
src/components/settings/web-service-settings.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import {
|
||||
startWebServer,
|
||||
stopWebServer,
|
||||
getWebServerStatus,
|
||||
type WebServerInfo,
|
||||
} from "@/lib/api"
|
||||
|
||||
export function WebServiceSettings() {
|
||||
const [status, setStatus] = useState<WebServerInfo | null>(null)
|
||||
const [port, setPort] = useState("3080")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const info = await getWebServerStatus()
|
||||
setStatus(info)
|
||||
if (info) {
|
||||
setPort(String(info.port))
|
||||
}
|
||||
} catch {
|
||||
// Server status unavailable
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [fetchStatus])
|
||||
|
||||
async function handleStart() {
|
||||
setError("")
|
||||
setLoading(true)
|
||||
try {
|
||||
const info = await startWebServer({
|
||||
port: parseInt(port, 10) || 3080,
|
||||
})
|
||||
setStatus(info)
|
||||
} catch (e: unknown) {
|
||||
const msg =
|
||||
e && typeof e === "object" && "message" in e
|
||||
? (e as { message: string }).message
|
||||
: "启动失败"
|
||||
setError(msg)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
setLoading(true)
|
||||
try {
|
||||
await stopWebServer()
|
||||
setStatus(null)
|
||||
} catch {
|
||||
setError("停止失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function copyToken() {
|
||||
if (status?.token) {
|
||||
navigator.clipboard.writeText(status.token)
|
||||
}
|
||||
}
|
||||
|
||||
function copyUrl() {
|
||||
if (status?.addresses?.[1]) {
|
||||
navigator.clipboard.writeText(status.addresses[1])
|
||||
} else if (status?.addresses?.[0]) {
|
||||
navigator.clipboard.writeText(status.addresses[0])
|
||||
}
|
||||
}
|
||||
|
||||
const isRunning = status !== null
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Web 服务</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
启用后可通过浏览器远程访问 Codeg
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Port config */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-20 text-sm font-medium">端口</label>
|
||||
<input
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
disabled={isRunning}
|
||||
min={1024}
|
||||
max={65535}
|
||||
className="flex h-9 w-32 rounded-md border border-input bg-background px-3 py-1 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Start/Stop button */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="w-20 text-sm font-medium">状态</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
isRunning ? "bg-green-500" : "bg-muted-foreground/30"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm">
|
||||
{isRunning ? "运行中" : "已停止"}
|
||||
</span>
|
||||
<button
|
||||
onClick={isRunning ? handleStop : handleStart}
|
||||
disabled={loading}
|
||||
className="inline-flex h-8 items-center rounded-md border border-input bg-background px-3 text-xs font-medium ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? "处理中..."
|
||||
: isRunning
|
||||
? "停止"
|
||||
: "启动"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Connection info */}
|
||||
{isRunning && (
|
||||
<div className="rounded-md border p-4 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
访问地址
|
||||
</div>
|
||||
{status.addresses.map((addr) => (
|
||||
<div key={addr} className="flex items-center gap-2">
|
||||
<code className="text-sm">{addr}</code>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={copyUrl}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
复制局域网地址
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
访问 Token
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-2 py-0.5 text-xs">
|
||||
{status.token}
|
||||
</code>
|
||||
<button
|
||||
onClick={copyToken}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Web 客户端首次访问时需输入此 Token
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import { listen } from "@tauri-apps/api/event"
|
||||
import { terminalWrite, terminalResize } from "@/lib/tauri"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import { subscribe } from "@/lib/platform"
|
||||
import { terminalWrite, terminalResize } from "@/lib/api"
|
||||
import type { TerminalEvent } from "@/lib/types"
|
||||
import type { ITheme } from "@xterm/xterm"
|
||||
|
||||
@@ -169,14 +168,14 @@ export function TerminalView({
|
||||
)
|
||||
|
||||
// Set up event listeners BEFORE fit so initial output is captured
|
||||
const unlisten = await listen<TerminalEvent>(
|
||||
const unlisten = await subscribe<TerminalEvent>(
|
||||
`terminal://output/${terminalId}`,
|
||||
(event) => {
|
||||
term.write(event.payload.data)
|
||||
(payload) => {
|
||||
term.write(payload.data)
|
||||
}
|
||||
)
|
||||
|
||||
const unlistenExit = await listen<TerminalEvent>(
|
||||
const unlistenExit = await subscribe<TerminalEvent>(
|
||||
`terminal://exit/${terminalId}`,
|
||||
() => {
|
||||
term.write("\r\n\x1b[90m[Process exited]\x1b[0m\r\n")
|
||||
@@ -187,8 +186,8 @@ export function TerminalView({
|
||||
themeObserver.disconnect()
|
||||
onDataDisposable.dispose()
|
||||
onResizeDisposable.dispose()
|
||||
disposeTauriListener(unlisten, "TerminalView.output")
|
||||
disposeTauriListener(unlistenExit, "TerminalView.exit")
|
||||
unlisten()
|
||||
unlistenExit()
|
||||
term.dispose()
|
||||
return
|
||||
}
|
||||
@@ -222,8 +221,8 @@ export function TerminalView({
|
||||
themeObserver.disconnect()
|
||||
onDataDisposable.dispose()
|
||||
onResizeDisposable.dispose()
|
||||
disposeTauriListener(unlisten, "TerminalView.output")
|
||||
disposeTauriListener(unlistenExit, "TerminalView.exit")
|
||||
unlisten()
|
||||
unlistenExit()
|
||||
resizeObserver.disconnect()
|
||||
term.dispose()
|
||||
fitAddonRef.current = null
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { open } from "@tauri-apps/plugin-dialog"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { cloneRepository, openFolderWindow } from "@/lib/tauri"
|
||||
import { cloneRepository, openFolderWindow } from "@/lib/api"
|
||||
import { openFileDialog } from "@/lib/platform"
|
||||
import { useGitCredential } from "@/contexts/git-credential-context"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -36,9 +36,9 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
|
||||
} | null>(null)
|
||||
|
||||
const handleBrowse = async () => {
|
||||
const selected = await open({ directory: true, multiple: false })
|
||||
const selected = await openFileDialog({ directory: true, multiple: false })
|
||||
if (selected) {
|
||||
setTargetDir(selected)
|
||||
setTargetDir(Array.isArray(selected) ? selected[0] : selected)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useState } from "react"
|
||||
import { FolderOpen, GitBranch } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { open } from "@tauri-apps/plugin-dialog"
|
||||
import { openFolderWindow } from "@/lib/tauri"
|
||||
import { openFolderWindow } from "@/lib/api"
|
||||
import { openFileDialog } from "@/lib/platform"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { CloneDialog } from "./clone-dialog"
|
||||
import { resolveWelcomeError } from "@/components/welcome/error-utils"
|
||||
@@ -15,8 +15,9 @@ export function FolderActions() {
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
|
||||
const handleOpen = async () => {
|
||||
const selected = await open({ directory: true, multiple: false })
|
||||
if (!selected) return
|
||||
const result = await openFileDialog({ directory: true, multiple: false })
|
||||
if (!result) return
|
||||
const selected = Array.isArray(result) ? result[0] : result
|
||||
|
||||
try {
|
||||
await openFolderWindow(selected)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { formatDistanceToNow } from "date-fns"
|
||||
import { enUS, zhCN, zhTW } from "date-fns/locale"
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { openFolderWindow, removeFolderFromHistory } from "@/lib/tauri"
|
||||
import { openFolderWindow, removeFolderFromHistory } from "@/lib/api"
|
||||
import type { FolderHistoryEntry } from "@/lib/types"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { resolveWelcomeError } from "@/components/welcome/error-utils"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react"
|
||||
import { Settings } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { loadFolderHistory, openSettingsWindow } from "@/lib/tauri"
|
||||
import { loadFolderHistory, openSettingsWindow } from "@/lib/api"
|
||||
import type { FolderHistoryEntry } from "@/lib/types"
|
||||
import { FolderList } from "@/components/welcome/folder-list"
|
||||
import { FolderActions } from "@/components/welcome/folder-actions"
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
|
||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
||||
import { subscribe } from "@/lib/platform"
|
||||
import { inferLiveToolName } from "@/lib/tool-call-normalization"
|
||||
import {
|
||||
acpConnect,
|
||||
@@ -22,7 +21,7 @@ import {
|
||||
acpCancel,
|
||||
acpRespondPermission,
|
||||
acpDisconnect,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
import type {
|
||||
AgentType,
|
||||
AcpAgentStatus,
|
||||
@@ -1609,25 +1608,24 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
// Single global event listener
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let unlisten: UnlistenFn | null = null
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
listenerReadyRef.current = false
|
||||
|
||||
listen<AcpEvent>("acp://event", (event) => {
|
||||
const e = event.payload
|
||||
const contextKey = reverseMapRef.current.get(e.connection_id)
|
||||
subscribe<AcpEvent>("acp://event", (payload) => {
|
||||
const contextKey = reverseMapRef.current.get(payload.connection_id)
|
||||
if (!contextKey) {
|
||||
bufferUnmappedEvent(e)
|
||||
bufferUnmappedEvent(payload)
|
||||
return
|
||||
}
|
||||
|
||||
// Touch activity on every incoming event
|
||||
lastActivityRef.current.set(contextKey, Date.now())
|
||||
handleMappedEvent(contextKey, e)
|
||||
handleMappedEvent(contextKey, payload)
|
||||
})
|
||||
.then((fn) => {
|
||||
if (cancelled) {
|
||||
disposeTauriListener(fn, "AcpConnectionsProvider.globalEvent")
|
||||
fn()
|
||||
} else {
|
||||
unlisten = fn
|
||||
listenerReadyRef.current = true
|
||||
@@ -1647,7 +1645,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
||||
clearTimeout(flushTimerRef.current)
|
||||
flushTimerRef.current = null
|
||||
}
|
||||
disposeTauriListener(unlisten, "AcpConnectionsProvider.globalEvent")
|
||||
unlisten?.()
|
||||
}
|
||||
}, [bufferUnmappedEvent, handleMappedEvent, resolveListenerReadyWaiters])
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import type { LiveMessage } from "@/contexts/acp-connections-context"
|
||||
import { getFolderConversation } from "@/lib/tauri"
|
||||
import { getFolderConversation } from "@/lib/api"
|
||||
import type {
|
||||
DbConversationDetail,
|
||||
MessageTurn,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { toErrorMessage } from "@/lib/app-error"
|
||||
import { getFolder, listFolderConversations } from "@/lib/tauri"
|
||||
import { getFolder, listFolderConversations } from "@/lib/api"
|
||||
import type {
|
||||
AgentType,
|
||||
AgentStats,
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
KeyRound,
|
||||
Loader2,
|
||||
} from "lucide-react"
|
||||
import { openUrl } from "@tauri-apps/plugin-opener"
|
||||
import { openUrl } from "@/lib/platform"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
getGitHubAccounts,
|
||||
updateGitHubAccounts,
|
||||
saveAccountToken,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
||||
import { saveFolderOpenedConversations } from "@/lib/tauri"
|
||||
import { saveFolderOpenedConversations } from "@/lib/api"
|
||||
import type {
|
||||
AgentType,
|
||||
ConversationStatus,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react"
|
||||
import { terminalSpawn, terminalKill } from "@/lib/tauri"
|
||||
import { terminalSpawn, terminalKill } from "@/lib/api"
|
||||
import { useFolderContext } from "@/contexts/folder-context"
|
||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
readFileForEdit,
|
||||
readFilePreview,
|
||||
saveFileContent,
|
||||
} from "@/lib/tauri"
|
||||
} from "@/lib/api"
|
||||
import { languageFromPath } from "@/lib/language-detect"
|
||||
import {
|
||||
loadPersistedWorkspaceMode,
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"skills": "Skills",
|
||||
"shortcuts": "الاختصارات",
|
||||
"version_control": "التحكم بالإصدارات",
|
||||
"system": "النظام"
|
||||
"system": "النظام",
|
||||
"web_service": "خدمة الويب"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"skills": "Skills",
|
||||
"shortcuts": "Kurzbefehle",
|
||||
"version_control": "Versionskontrolle",
|
||||
"system": "Systemeinstellungen"
|
||||
"system": "Systemeinstellungen",
|
||||
"web_service": "Webdienst"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"skills": "Skills",
|
||||
"shortcuts": "Shortcuts",
|
||||
"version_control": "Version Control",
|
||||
"system": "System"
|
||||
"system": "System",
|
||||
"web_service": "Web Service"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"skills": "Skills",
|
||||
"shortcuts": "Atajos",
|
||||
"version_control": "Control de versiones",
|
||||
"system": "Sistema"
|
||||
"system": "Sistema",
|
||||
"web_service": "Servicio Web"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"skills": "Skills",
|
||||
"shortcuts": "Raccourcis",
|
||||
"version_control": "Contrôle de version",
|
||||
"system": "Système"
|
||||
"system": "Système",
|
||||
"web_service": "Service Web"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"skills": "Skills",
|
||||
"shortcuts": "ショートカット",
|
||||
"version_control": "バージョン管理",
|
||||
"system": "システム"
|
||||
"system": "システム",
|
||||
"web_service": "Webサービス"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"skills": "Skills",
|
||||
"shortcuts": "단축키",
|
||||
"version_control": "버전 관리",
|
||||
"system": "시스템"
|
||||
"system": "시스템",
|
||||
"web_service": "웹 서비스"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"skills": "Skills",
|
||||
"shortcuts": "Atalhos",
|
||||
"version_control": "Controle de versão",
|
||||
"system": "Sistema"
|
||||
"system": "Sistema",
|
||||
"web_service": "Serviço Web"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"skills": "Skills",
|
||||
"shortcuts": "快捷键",
|
||||
"version_control": "版本控制",
|
||||
"system": "系统"
|
||||
"system": "系统",
|
||||
"web_service": "Web 服务"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
|
||||
@@ -88,7 +88,8 @@
|
||||
"skills": "Skills",
|
||||
"shortcuts": "快捷鍵",
|
||||
"version_control": "版本控制",
|
||||
"system": "系統"
|
||||
"system": "系統",
|
||||
"web_service": "Web 服務"
|
||||
}
|
||||
},
|
||||
"AppearanceSettings": {
|
||||
|
||||
1151
src/lib/api.ts
Normal file
1151
src/lib/api.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,22 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { getTransport } from "./transport"
|
||||
import { isDesktop } from "./transport"
|
||||
|
||||
export async function notifyTurnComplete(
|
||||
title: string,
|
||||
body: string
|
||||
): Promise<void> {
|
||||
if (!document.hidden) return
|
||||
await invoke("send_notification", { title, body })
|
||||
if (isDesktop()) {
|
||||
await getTransport().call("send_notification", { title, body })
|
||||
} else {
|
||||
// Web fallback: Browser Notification API
|
||||
if (Notification.permission === "granted") {
|
||||
new Notification(title, { body })
|
||||
} else if (Notification.permission !== "denied") {
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission === "granted") {
|
||||
new Notification(title, { body })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
123
src/lib/platform.ts
Normal file
123
src/lib/platform.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { isDesktop, getTransport } from "./transport"
|
||||
import type { UnsubscribeFn } from "./transport"
|
||||
|
||||
/**
|
||||
* Platform-aware API wrappers for features that differ between
|
||||
* Tauri desktop and web browser environments.
|
||||
*/
|
||||
|
||||
export { isDesktop }
|
||||
|
||||
/**
|
||||
* Subscribe to backend events.
|
||||
* Uses Tauri listen() in desktop mode, WebSocket in web mode.
|
||||
*/
|
||||
export async function subscribe<T>(
|
||||
event: string,
|
||||
handler: (payload: T) => void
|
||||
): Promise<UnsubscribeFn> {
|
||||
return getTransport().subscribe(event, handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a URL in the default browser (desktop) or new tab (web).
|
||||
*/
|
||||
export async function openUrl(url: string): Promise<void> {
|
||||
if (isDesktop()) {
|
||||
const { openUrl: tauriOpenUrl } = await import(
|
||||
"@tauri-apps/plugin-opener"
|
||||
)
|
||||
await tauriOpenUrl(url)
|
||||
} else {
|
||||
window.open(url, "_blank")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a path in the system file manager (desktop only).
|
||||
* No-op in web mode.
|
||||
*/
|
||||
export async function openPath(path: string): Promise<void> {
|
||||
if (isDesktop()) {
|
||||
const { openPath: tauriOpenPath } = await import(
|
||||
"@tauri-apps/plugin-opener"
|
||||
)
|
||||
await tauriOpenPath(path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal a file/directory in the system file manager (desktop only).
|
||||
* No-op in web mode.
|
||||
*/
|
||||
export async function revealItemInDir(path: string): Promise<void> {
|
||||
if (isDesktop()) {
|
||||
const { revealItemInDir: tauriReveal } = await import(
|
||||
"@tauri-apps/plugin-opener"
|
||||
)
|
||||
await tauriReveal(path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a native file/directory dialog (desktop) or fallback (web).
|
||||
*/
|
||||
export async function openFileDialog(options?: {
|
||||
directory?: boolean
|
||||
multiple?: boolean
|
||||
title?: string
|
||||
}): Promise<string | string[] | null> {
|
||||
if (isDesktop()) {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog")
|
||||
return open(options ?? {})
|
||||
}
|
||||
// Web fallback: for directory selection, prompt for server-side path.
|
||||
// For file selection, use a hidden file input.
|
||||
if (options?.directory) {
|
||||
const path = window.prompt(
|
||||
options?.title ?? "输入服务端目录路径 (Enter server directory path)"
|
||||
)
|
||||
return path || null
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
if (options?.multiple) input.multiple = true
|
||||
input.onchange = () => {
|
||||
if (!input.files?.length) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
const paths = Array.from(input.files).map((f) => f.name)
|
||||
resolve(options?.multiple ? paths : paths[0])
|
||||
}
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current Tauri window (desktop only).
|
||||
* Returns null in web mode.
|
||||
*/
|
||||
export async function getCurrentWindow() {
|
||||
if (isDesktop()) {
|
||||
const { getCurrentWindow: tauriGetCurrentWindow } = await import(
|
||||
"@tauri-apps/api/window"
|
||||
)
|
||||
return tauriGetCurrentWindow()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the current window.
|
||||
* Desktop: closes Tauri window. Web: navigates back or closes tab.
|
||||
*/
|
||||
export async function closeCurrentWindow(): Promise<void> {
|
||||
if (isDesktop()) {
|
||||
const win = await getCurrentWindow()
|
||||
await win?.close()
|
||||
} else {
|
||||
window.history.back()
|
||||
}
|
||||
}
|
||||
11
src/lib/transport/detect.ts
Normal file
11
src/lib/transport/detect.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type TransportEnvironment = "tauri" | "web"
|
||||
|
||||
export function detectEnvironment(): TransportEnvironment {
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
"__TAURI_INTERNALS__" in window
|
||||
) {
|
||||
return "tauri"
|
||||
}
|
||||
return "web"
|
||||
}
|
||||
33
src/lib/transport/index.ts
Normal file
33
src/lib/transport/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { detectEnvironment } from "./detect"
|
||||
import type { Transport } from "./types"
|
||||
|
||||
export type { Transport, UnsubscribeFn } from "./types"
|
||||
|
||||
let _transport: Transport | null = null
|
||||
|
||||
export function getTransport(): Transport {
|
||||
if (!_transport) {
|
||||
const env = detectEnvironment()
|
||||
if (env === "tauri") {
|
||||
// Use dynamic require to avoid bundling tauri deps in web mode.
|
||||
// TauriTransport uses dynamic imports internally.
|
||||
const { TauriTransport } = require("./tauri-transport") as {
|
||||
TauriTransport: new () => Transport
|
||||
}
|
||||
_transport = new TauriTransport()
|
||||
} else {
|
||||
const { WebTransport } = require("./web-transport") as {
|
||||
WebTransport: new (baseUrl: string) => Transport
|
||||
}
|
||||
// In web mode, the API is served from the same origin.
|
||||
// Token is read from localStorage on each request.
|
||||
const baseUrl = window.location.origin
|
||||
_transport = new WebTransport(baseUrl)
|
||||
}
|
||||
}
|
||||
return _transport
|
||||
}
|
||||
|
||||
export function isDesktop(): boolean {
|
||||
return getTransport().isDesktop()
|
||||
}
|
||||
23
src/lib/transport/tauri-transport.ts
Normal file
23
src/lib/transport/tauri-transport.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Transport, UnsubscribeFn } from "./types"
|
||||
|
||||
export class TauriTransport implements Transport {
|
||||
async call<T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const { invoke } = await import("@tauri-apps/api/core")
|
||||
return invoke(command, args)
|
||||
}
|
||||
|
||||
async subscribe<T>(
|
||||
event: string,
|
||||
handler: (payload: T) => void
|
||||
): Promise<UnsubscribeFn> {
|
||||
const { listen } = await import("@tauri-apps/api/event")
|
||||
return listen<T>(event, (e) => handler(e.payload))
|
||||
}
|
||||
|
||||
isDesktop(): boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
22
src/lib/transport/types.ts
Normal file
22
src/lib/transport/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type UnsubscribeFn = () => void
|
||||
|
||||
export interface Transport {
|
||||
/**
|
||||
* Invoke a backend command (replaces Tauri's invoke()).
|
||||
*/
|
||||
call<T>(command: string, args?: Record<string, unknown>): Promise<T>
|
||||
|
||||
/**
|
||||
* Subscribe to a backend event stream (replaces Tauri's listen()).
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
subscribe<T>(
|
||||
event: string,
|
||||
handler: (payload: T) => void
|
||||
): Promise<UnsubscribeFn>
|
||||
|
||||
/**
|
||||
* Whether the app is running in a desktop Tauri environment.
|
||||
*/
|
||||
isDesktop(): boolean
|
||||
}
|
||||
130
src/lib/transport/web-transport.ts
Normal file
130
src/lib/transport/web-transport.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { Transport, UnsubscribeFn } from "./types"
|
||||
|
||||
interface WebEvent {
|
||||
channel: string
|
||||
payload: unknown
|
||||
}
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("codeg_token") ?? ""
|
||||
}
|
||||
|
||||
export class WebTransport implements Transport {
|
||||
private ws: WebSocket | null = null
|
||||
private handlers = new Map<string, Set<(payload: unknown) => void>>()
|
||||
private baseUrl: string
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private wsFailCount = 0
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl
|
||||
}
|
||||
|
||||
async call<T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>
|
||||
): Promise<T> {
|
||||
const token = getToken()
|
||||
const res = await fetch(`${this.baseUrl}/api/${command}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(args ?? {}),
|
||||
})
|
||||
if (res.status === 401) {
|
||||
WebTransport.redirectToLogin()
|
||||
throw new Error("Unauthorized")
|
||||
}
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({
|
||||
code: "network_error",
|
||||
message: `HTTP ${res.status}`,
|
||||
}))
|
||||
throw error
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async subscribe<T>(
|
||||
event: string,
|
||||
handler: (payload: T) => void
|
||||
): Promise<UnsubscribeFn> {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, new Set())
|
||||
}
|
||||
const wrappedHandler = handler as (payload: unknown) => void
|
||||
this.handlers.get(event)!.add(wrappedHandler)
|
||||
|
||||
// If WS is not connected but we now have a token, connect
|
||||
if (!this.ws && getToken()) {
|
||||
this.connectWs()
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.handlers.get(event)?.delete(wrappedHandler)
|
||||
}
|
||||
}
|
||||
|
||||
isDesktop(): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
private static redirectToLogin() {
|
||||
if (window.location.pathname.startsWith("/login")) return
|
||||
localStorage.removeItem("codeg_token")
|
||||
window.location.href = "/login"
|
||||
}
|
||||
|
||||
private connectWs() {
|
||||
const token = getToken()
|
||||
if (!token) return
|
||||
|
||||
const wsUrl =
|
||||
this.baseUrl.replace(/^http/, "ws") +
|
||||
`/ws/events?token=${encodeURIComponent(token)}`
|
||||
this.ws = new WebSocket(wsUrl)
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.wsFailCount = 0
|
||||
}
|
||||
|
||||
this.ws.onmessage = (msg) => {
|
||||
try {
|
||||
const event = JSON.parse(msg.data) as WebEvent
|
||||
const handlers = this.handlers.get(event.channel)
|
||||
if (handlers) {
|
||||
for (const h of handlers) {
|
||||
h(event.payload)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed messages
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this.ws = null
|
||||
this.wsFailCount++
|
||||
if (this.wsFailCount >= 3) {
|
||||
WebTransport.redirectToLogin()
|
||||
return
|
||||
}
|
||||
this.reconnectTimer = setTimeout(() => this.connectWs(), 3000)
|
||||
}
|
||||
|
||||
this.ws.onerror = () => {
|
||||
this.ws?.close()
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
}
|
||||
this.ws?.close()
|
||||
this.ws = null
|
||||
this.handlers.clear()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getVersion } from "@tauri-apps/api/app"
|
||||
import { relaunch } from "@tauri-apps/plugin-process"
|
||||
import { check, type Update } from "@tauri-apps/plugin-updater"
|
||||
// All updater imports are dynamic to avoid crashing in non-Tauri browsers.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Update = any
|
||||
|
||||
export interface AppUpdateCheckResult {
|
||||
currentVersion: string
|
||||
@@ -20,23 +20,31 @@ export interface AppUpdateErrorInfo {
|
||||
}
|
||||
|
||||
export async function getCurrentAppVersion(): Promise<string> {
|
||||
return getVersion()
|
||||
try {
|
||||
const { getVersion } = await import("@tauri-apps/api/app")
|
||||
return await getVersion()
|
||||
} catch {
|
||||
return "web"
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAppUpdate(): Promise<AppUpdateCheckResult> {
|
||||
const { getVersion } = await import("@tauri-apps/api/app")
|
||||
const { check } = await import("@tauri-apps/plugin-updater")
|
||||
const [currentVersion, update] = await Promise.all([getVersion(), check()])
|
||||
return { currentVersion, update }
|
||||
}
|
||||
|
||||
export async function installAppUpdate(update: Update): Promise<void> {
|
||||
export async function installAppUpdate(update: NonNullable<Update>): Promise<void> {
|
||||
await update.downloadAndInstall()
|
||||
}
|
||||
|
||||
export async function relaunchApp(): Promise<void> {
|
||||
const { relaunch } = await import("@tauri-apps/plugin-process")
|
||||
await relaunch()
|
||||
}
|
||||
|
||||
export async function closeAppUpdate(update: Update): Promise<void> {
|
||||
export async function closeAppUpdate(update: NonNullable<Update>): Promise<void> {
|
||||
await update.close()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user