mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-04 13:22:41 +02:00
Merge upstream/dev
This commit is contained in:
commit
2d962f6dd2
107 changed files with 15033 additions and 2277 deletions
|
|
@ -15,7 +15,7 @@ import {
|
|||
DownloadIcon,
|
||||
ExternalLink,
|
||||
Globe,
|
||||
MessageSquare,
|
||||
MessageCircleReply,
|
||||
MoreHorizontalIcon,
|
||||
RefreshCwIcon,
|
||||
} from "lucide-react";
|
||||
|
|
@ -657,7 +657,7 @@ export const AssistantMessage: FC = () => {
|
|||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
<MessageSquare className={cn("size-3.5", hasComments && "fill-current")} />
|
||||
<MessageCircleReply className={cn("size-3.5", hasComments && "fill-current")} />
|
||||
{hasComments ? (
|
||||
<span>
|
||||
{commentCount} {commentCount === 1 ? "comment" : "comments"}
|
||||
|
|
|
|||
|
|
@ -1,311 +1,187 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { Check, Copy, Info } from "lucide-react";
|
||||
import { type FC, useCallback, useRef, useState } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { useApiKey } from "@/hooks/use-api-key";
|
||||
import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils";
|
||||
import { getConnectorBenefits } from "../connector-benefits";
|
||||
import type { ConnectFormProps } from "../index";
|
||||
|
||||
const obsidianConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
vault_path: z.string().min(1, {
|
||||
message: "Vault path is required.",
|
||||
}),
|
||||
vault_name: z.string().min(1, {
|
||||
message: "Vault name is required.",
|
||||
}),
|
||||
exclude_folders: z.string().optional(),
|
||||
include_attachments: z.boolean(),
|
||||
});
|
||||
const PLUGIN_RELEASES_URL =
|
||||
"https://github.com/MODSetter/SurfSense/releases?q=obsidian&expanded=true";
|
||||
|
||||
type ObsidianConnectorFormValues = z.infer<typeof obsidianConnectorFormSchema>;
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ?? "https://surfsense.com";
|
||||
|
||||
export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
|
||||
const isSubmittingRef = useRef(false);
|
||||
const [periodicEnabled, setPeriodicEnabled] = useState(true);
|
||||
const [frequencyMinutes, setFrequencyMinutes] = useState("60");
|
||||
const form = useForm<ObsidianConnectorFormValues>({
|
||||
resolver: zodResolver(obsidianConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Obsidian Vault",
|
||||
vault_path: "",
|
||||
vault_name: "",
|
||||
exclude_folders: ".obsidian,.trash",
|
||||
include_attachments: false,
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Obsidian connect form for the plugin-only architecture.
|
||||
*
|
||||
* The legacy `vault_path` form was removed because it only worked on
|
||||
* self-hosted with a server-side bind mount and broke for everyone else.
|
||||
* The plugin pushes data over HTTPS so this UI is purely instructional —
|
||||
* there is no backend create call here. The connector row is created
|
||||
* server-side the first time the plugin calls `POST /obsidian/connect`.
|
||||
*
|
||||
* The footer "Connect" button in `ConnectorConnectView` triggers this
|
||||
* form's submit; we just close the dialog (`onBack()`) since there's
|
||||
* nothing to validate or persist from this side.
|
||||
*/
|
||||
export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onBack }) => {
|
||||
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
|
||||
const [copiedUrl, setCopiedUrl] = useState(false);
|
||||
const urlCopyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
const handleSubmit = async (values: ObsidianConnectorFormValues) => {
|
||||
// Prevent multiple submissions
|
||||
if (isSubmittingRef.current || isSubmitting) {
|
||||
return;
|
||||
}
|
||||
const copyServerUrl = useCallback(async () => {
|
||||
const ok = await copyToClipboardUtil(BACKEND_URL);
|
||||
if (!ok) return;
|
||||
setCopiedUrl(true);
|
||||
if (urlCopyTimerRef.current) clearTimeout(urlCopyTimerRef.current);
|
||||
urlCopyTimerRef.current = setTimeout(() => setCopiedUrl(false), 2000);
|
||||
}, []);
|
||||
|
||||
isSubmittingRef.current = true;
|
||||
try {
|
||||
// Parse exclude_folders into an array
|
||||
const excludeFolders = values.exclude_folders
|
||||
? values.exclude_folders
|
||||
.split(",")
|
||||
.map((f) => f.trim())
|
||||
.filter(Boolean)
|
||||
: [".obsidian", ".trash"];
|
||||
|
||||
await onSubmit({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.OBSIDIAN_CONNECTOR,
|
||||
config: {
|
||||
vault_path: values.vault_path,
|
||||
vault_name: values.vault_name,
|
||||
exclude_folders: excludeFolders,
|
||||
include_attachments: values.include_attachments,
|
||||
},
|
||||
is_indexable: true,
|
||||
is_active: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: periodicEnabled ? Number.parseInt(frequencyMinutes, 10) : null,
|
||||
next_scheduled_at: null,
|
||||
periodicEnabled,
|
||||
frequencyMinutes,
|
||||
});
|
||||
} finally {
|
||||
isSubmittingRef.current = false;
|
||||
}
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
onBack();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-purple-500/10 dark:bg-purple-500/10 border-purple-500/30 p-2 sm:p-3">
|
||||
{/* Form is intentionally empty so the footer Connect button is a no-op
|
||||
that just closes the dialog (see component-level docstring). */}
|
||||
<form id="obsidian-connect-form" onSubmit={handleSubmit} />
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3">
|
||||
<Info className="size-4 shrink-0 text-purple-500" />
|
||||
<AlertTitle className="text-xs sm:text-sm">Self-Hosted Only</AlertTitle>
|
||||
<AlertTitle className="text-xs sm:text-sm">Plugin-based sync</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
This connector requires direct file system access and only works with self-hosted
|
||||
SurfSense installations.
|
||||
SurfSense now syncs Obsidian via an official plugin that runs inside Obsidian itself.
|
||||
Works on desktop and mobile, in cloud and self-hosted deployments.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="obsidian-connect-form"
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="space-y-4 sm:space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My Obsidian Vault"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="vault_path"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Vault Path</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="/path/to/your/obsidian/vault"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
The absolute path to your Obsidian vault on the server. This must be accessible
|
||||
from the SurfSense backend.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="vault_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Vault Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="My Knowledge Base"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
A display name for your vault. This will be used in search results.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="exclude_folders"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Exclude Folders</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder=".obsidian,.trash,templates"
|
||||
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
|
||||
disabled={isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Comma-separated list of folder names to exclude from indexing.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="include_attachments"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border border-slate-400/20 p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-xs sm:text-sm">Include Attachments</FormLabel>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Index attachment folders and embedded files (images, PDFs, etc.)
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Indexing Configuration */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-400/20">
|
||||
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
|
||||
|
||||
{/* Periodic Sync Config */}
|
||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Automatically re-index at regular intervals
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={periodicEnabled}
|
||||
onCheckedChange={setPeriodicEnabled}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{periodicEnabled && (
|
||||
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="frequency" className="text-xs sm:text-sm">
|
||||
Sync Frequency
|
||||
</Label>
|
||||
<Select
|
||||
value={frequencyMinutes}
|
||||
onValueChange={setFrequencyMinutes}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="frequency"
|
||||
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-100">
|
||||
<SelectItem value="5" className="text-xs sm:text-sm">
|
||||
Every 5 minutes
|
||||
</SelectItem>
|
||||
<SelectItem value="15" className="text-xs sm:text-sm">
|
||||
Every 15 minutes
|
||||
</SelectItem>
|
||||
<SelectItem value="60" className="text-xs sm:text-sm">
|
||||
Every hour
|
||||
</SelectItem>
|
||||
<SelectItem value="360" className="text-xs sm:text-sm">
|
||||
Every 6 hours
|
||||
</SelectItem>
|
||||
<SelectItem value="720" className="text-xs sm:text-sm">
|
||||
Every 12 hours
|
||||
</SelectItem>
|
||||
<SelectItem value="1440" className="text-xs sm:text-sm">
|
||||
Daily
|
||||
</SelectItem>
|
||||
<SelectItem value="10080" className="text-xs sm:text-sm">
|
||||
Weekly
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<section className="rounded-xl border border-border bg-slate-400/5 p-3 sm:p-6 dark:bg-white/5">
|
||||
<div className="space-y-5 sm:space-y-6">
|
||||
{/* Step 1 — Install plugin */}
|
||||
<article>
|
||||
<header className="mb-3 flex items-center gap-2">
|
||||
<div className="flex size-7 items-center justify-center rounded-md border border-slate-400/30 text-xs font-medium">
|
||||
1
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium sm:text-base">Install the plugin</h3>
|
||||
</header>
|
||||
<p className="mb-3 text-[11px] text-muted-foreground sm:text-xs">
|
||||
Grab the latest SurfSense plugin release. Once it's in the community store, you'll
|
||||
also be able to install it from{" "}
|
||||
<span className="font-medium">Settings → Community plugins</span> inside Obsidian.
|
||||
</p>
|
||||
<a
|
||||
href={PLUGIN_RELEASES_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="gap-2 text-xs sm:text-sm"
|
||||
>
|
||||
Open plugin releases
|
||||
</Button>
|
||||
</a>
|
||||
</article>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
{/* Step 2 — Copy API key */}
|
||||
<article>
|
||||
<header className="mb-3 flex items-center gap-2">
|
||||
<div className="flex size-7 items-center justify-center rounded-md border border-slate-400/30 text-xs font-medium">
|
||||
2
|
||||
</div>
|
||||
<h3 className="text-sm font-medium sm:text-base">Copy your API key</h3>
|
||||
</header>
|
||||
<p className="mb-3 text-[11px] text-muted-foreground sm:text-xs">
|
||||
Paste this into the plugin's <span className="font-medium">API token</span> setting.
|
||||
The token expires after 24 hours. Long-lived personal access tokens are coming in a
|
||||
future release.
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-10 w-full animate-pulse rounded-md border border-border/60 bg-muted/30" />
|
||||
) : apiKey ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-2.5 py-1.5">
|
||||
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-hide">
|
||||
<p className="cursor-text select-all whitespace-nowrap font-mono text-[10px] text-muted-foreground">
|
||||
{apiKey}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={copyToClipboard}
|
||||
className="size-7 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={copied ? "Copied" : "Copy API key"}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-xs text-muted-foreground/60">
|
||||
No API key available — try refreshing the page.
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
{/* Step 3 — Server URL */}
|
||||
<article>
|
||||
<header className="mb-3 flex items-center gap-2">
|
||||
<div className="flex size-7 items-center justify-center rounded-md border border-slate-400/30 text-xs font-medium">
|
||||
3
|
||||
</div>
|
||||
<h3 className="text-sm font-medium sm:text-base">Point the plugin at this server</h3>
|
||||
</header>
|
||||
<p className="text-[11px] text-muted-foreground sm:text-xs">
|
||||
For SurfSense Cloud, use the default{" "}
|
||||
<span className="font-medium">surfsense.com</span>. If you are self-hosting, set the
|
||||
plugin's <span className="font-medium">Server URL</span> to your frontend domain.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<div className="h-px bg-border/60" />
|
||||
|
||||
{/* Step 4 — Pick search space */}
|
||||
<article>
|
||||
<header className="mb-3 flex items-center gap-2">
|
||||
<div className="flex size-7 items-center justify-center rounded-md border border-slate-400/30 text-xs font-medium">
|
||||
4
|
||||
</div>
|
||||
<h3 className="text-sm font-medium sm:text-base">Pick this search space</h3>
|
||||
</header>
|
||||
<p className="text-[11px] text-muted-foreground sm:text-xs">
|
||||
In the plugin's <span className="font-medium">Search space</span> setting, choose the
|
||||
search space you want this vault to sync into. The connector will appear here
|
||||
automatically once the plugin makes its first sync.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What you get section */}
|
||||
{getConnectorBenefits(EnumConnectorName.OBSIDIAN_CONNECTOR) && (
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
||||
<h4 className="text-xs sm:text-sm font-medium">
|
||||
<div className="space-y-2 rounded-xl border border-border bg-slate-400/5 px-3 py-4 sm:px-6 dark:bg-white/5">
|
||||
<h4 className="text-xs font-medium sm:text-sm">
|
||||
What you get with Obsidian integration:
|
||||
</h4>
|
||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
<ul className="list-disc space-y-1 pl-5 text-[10px] text-muted-foreground sm:text-xs">
|
||||
{getConnectorBenefits(EnumConnectorName.OBSIDIAN_CONNECTOR)?.map((benefit) => (
|
||||
<li key={benefit}>{benefit}</li>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -104,11 +104,11 @@ export function getConnectorBenefits(connectorType: string): string[] | null {
|
|||
"No manual indexing required - meetings are added automatically",
|
||||
],
|
||||
OBSIDIAN_CONNECTOR: [
|
||||
"Search through all your Obsidian notes and knowledge base",
|
||||
"Access note content with YAML frontmatter metadata preserved",
|
||||
"Wiki-style links ([[note]]) and #tags are indexed",
|
||||
"Connect your personal knowledge base directly to your search space",
|
||||
"Incremental sync - only changed files are re-indexed",
|
||||
"Search through all of your Obsidian notes",
|
||||
"Realtime sync as you create, edit, rename, or delete notes",
|
||||
"YAML frontmatter, [[wiki links]], and #tags are preserved and indexed",
|
||||
"Open any chat citation straight back in Obsidian via deep links",
|
||||
"Each device is identifiable, so you can revoke a vault from one machine",
|
||||
"Full support for your vault's folder structure",
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,167 +1,162 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { AlertTriangle, Info } from "lucide-react";
|
||||
import { type FC, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { connectorsApiService, type ObsidianStats } from "@/lib/apis/connectors-api.service";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface ObsidianConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
const OBSIDIAN_SETUP_DOCS_URL = "/docs/connectors/obsidian";
|
||||
|
||||
function formatTimestamp(value: unknown): string {
|
||||
if (typeof value !== "string" || !value) return "—";
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return value;
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
export const ObsidianConfig: FC<ObsidianConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
onNameChange,
|
||||
}) => {
|
||||
const [vaultPath, setVaultPath] = useState<string>(
|
||||
(connector.config?.vault_path as string) || ""
|
||||
);
|
||||
const [vaultName, setVaultName] = useState<string>(
|
||||
(connector.config?.vault_name as string) || ""
|
||||
);
|
||||
const [excludeFolders, setExcludeFolders] = useState<string>(() => {
|
||||
const folders = connector.config?.exclude_folders;
|
||||
if (Array.isArray(folders)) {
|
||||
return folders.join(", ");
|
||||
}
|
||||
return (folders as string) || ".obsidian, .trash";
|
||||
});
|
||||
const [includeAttachments, setIncludeAttachments] = useState<boolean>(
|
||||
(connector.config?.include_attachments as boolean) || false
|
||||
);
|
||||
const [name, setName] = useState<string>(connector.name || "");
|
||||
/**
|
||||
* Obsidian connector config view.
|
||||
*
|
||||
* Read-only on purpose: the plugin owns vault identity, so the connector's
|
||||
* display name is auto-derived from `payload.vault_name` server-side on
|
||||
* every `/connect` (see `obsidian_plugin_routes.obsidian_connect`). The
|
||||
* web UI doesn't expose a Name input or a Save button for Obsidian (the
|
||||
* latter is suppressed in `connector-edit-view.tsx`).
|
||||
*
|
||||
* Renders one of three modes depending on the connector's `config`:
|
||||
*
|
||||
* 1. **Plugin connector** (`config.source === "plugin"`) — read-only stats
|
||||
* panel showing what the plugin most recently reported.
|
||||
* 2. **Legacy server-path connector** (`config.legacy === true`, set by the
|
||||
* migration) — migration warning + docs link + explicit disconnect data-loss
|
||||
* warning so users move to the plugin flow safely.
|
||||
* 3. **Unknown** — fallback for rows that escaped migration; suggests a
|
||||
* clean re-install.
|
||||
*/
|
||||
export const ObsidianConfig: FC<ConnectorConfigProps> = ({ connector }) => {
|
||||
const config = (connector.config ?? {}) as Record<string, unknown>;
|
||||
const isLegacy = config.legacy === true;
|
||||
const isPlugin = config.source === "plugin";
|
||||
|
||||
const handleVaultPathChange = (value: string) => {
|
||||
setVaultPath(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
vault_path: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleVaultNameChange = (value: string) => {
|
||||
setVaultName(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
vault_name: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleExcludeFoldersChange = (value: string) => {
|
||||
setExcludeFolders(value);
|
||||
const foldersArray = value
|
||||
.split(",")
|
||||
.map((f) => f.trim())
|
||||
.filter(Boolean);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
exclude_folders: foldersArray,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleIncludeAttachmentsChange = (value: boolean) => {
|
||||
setIncludeAttachments(value);
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
include_attachments: value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
};
|
||||
if (isLegacy) return <LegacyBanner />;
|
||||
if (isPlugin) return <PluginStats config={config} />;
|
||||
return <UnknownConnectorState />;
|
||||
};
|
||||
|
||||
const LegacyBanner: FC = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Connector Name */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Connector Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="My Obsidian Vault"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A friendly name to identify this connector.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Alert className="border-amber-500/40 bg-amber-500/10">
|
||||
<AlertTriangle className="size-4 shrink-0 text-amber-500" />
|
||||
<AlertTitle className="text-xs sm:text-sm">
|
||||
Sync stopped, install the plugin to migrate
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-[11px] sm:text-xs leading-relaxed">
|
||||
This Obsidian connector used the legacy server-path scanner, which has been removed. The
|
||||
notes already indexed remain searchable, but they no longer reflect changes made in your
|
||||
vault.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2">
|
||||
Vault Configuration
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Vault Path</Label>
|
||||
<Input
|
||||
value={vaultPath}
|
||||
onChange={(e) => handleVaultPathChange(e.target.value)}
|
||||
placeholder="/path/to/your/obsidian/vault"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The absolute path to your Obsidian vault on the server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Vault Name</Label>
|
||||
<Input
|
||||
value={vaultName}
|
||||
onChange={(e) => handleVaultNameChange(e.target.value)}
|
||||
placeholder="My Knowledge Base"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
A display name for your vault in search results.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs sm:text-sm">Exclude Folders</Label>
|
||||
<Input
|
||||
value={excludeFolders}
|
||||
onChange={(e) => handleExcludeFoldersChange(e.target.value)}
|
||||
placeholder=".obsidian, .trash, templates"
|
||||
className="border-slate-400/20 focus-visible:border-slate-400/40 font-mono"
|
||||
/>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Comma-separated list of folder names to exclude from indexing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-400/20 p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-xs sm:text-sm">Include Attachments</Label>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Index attachment folders and embedded files
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={includeAttachments} onCheckedChange={handleIncludeAttachmentsChange} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 p-3 sm:p-6 dark:bg-white/5">
|
||||
<h3 className="mb-3 text-sm font-medium sm:text-base">Migration required</h3>
|
||||
<p className="mb-3 text-[11px] leading-relaxed text-muted-foreground sm:text-xs">
|
||||
Follow the{" "}
|
||||
<a
|
||||
href={OBSIDIAN_SETUP_DOCS_URL}
|
||||
className="font-medium text-primary underline underline-offset-4 hover:text-primary/80"
|
||||
>
|
||||
Obsidian setup guide
|
||||
</a>{" "}
|
||||
to reconnect this vault through the plugin.
|
||||
</p>
|
||||
<p className="text-[11px] leading-relaxed text-amber-600 dark:text-amber-400 sm:text-xs">
|
||||
Heads up: Disconnect also deletes every document this connector previously indexed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PluginStats: FC<{ config: Record<string, unknown> }> = ({ config }) => {
|
||||
const vaultId = typeof config.vault_id === "string" ? config.vault_id : null;
|
||||
const [stats, setStats] = useState<ObsidianStats | null>(null);
|
||||
const [statsError, setStatsError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!vaultId) return;
|
||||
let cancelled = false;
|
||||
setStats(null);
|
||||
setStatsError(false);
|
||||
connectorsApiService
|
||||
.getObsidianStats(vaultId)
|
||||
.then((result) => {
|
||||
if (!cancelled) setStats(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.error("Failed to fetch Obsidian stats", err);
|
||||
setStatsError(true);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [vaultId]);
|
||||
|
||||
const tileRows = useMemo(() => {
|
||||
const placeholder = statsError ? "—" : stats ? null : "…";
|
||||
return [
|
||||
{ label: "Vault name", value: (config.vault_name as string) || "—" },
|
||||
{
|
||||
label: "Last sync",
|
||||
value: placeholder ?? formatTimestamp(stats?.last_sync_at ?? null),
|
||||
},
|
||||
{
|
||||
label: "Files synced",
|
||||
value:
|
||||
placeholder ??
|
||||
(typeof stats?.files_synced === "number" ? stats.files_synced.toLocaleString() : "—"),
|
||||
},
|
||||
];
|
||||
}, [config.vault_name, stats, statsError]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert className="border-emerald-500/30 bg-emerald-500/10">
|
||||
<Info className="size-4 shrink-0 text-emerald-500" />
|
||||
<AlertTitle className="text-xs sm:text-sm">Plugin connected</AlertTitle>
|
||||
<AlertDescription className="text-[11px] sm:text-xs">
|
||||
Your notes stay synced automatically. To stop syncing, disable or uninstall the plugin in
|
||||
Obsidian, or delete this connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-xl bg-slate-400/5 p-3 sm:p-6 dark:bg-white/5">
|
||||
<h3 className="mb-3 text-sm font-medium sm:text-base">Vault Status</h3>
|
||||
<dl className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{tileRows.map((stat) => (
|
||||
<div key={stat.label} className="rounded-lg bg-background/50 p-3">
|
||||
<dt className="text-xs tracking-wide text-muted-foreground sm:text-sm">
|
||||
{stat.label}
|
||||
</dt>
|
||||
<dd className="mt-1 truncate text-xs font-medium sm:text-sm">{stat.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UnknownConnectorState: FC = () => (
|
||||
<Alert>
|
||||
<Info className="size-4 shrink-0" />
|
||||
<AlertTitle className="text-xs sm:text-sm">Unrecognized config</AlertTitle>
|
||||
<AlertDescription className="text-[11px] sm:text-xs">
|
||||
This connector has neither plugin metadata nor a legacy marker. It may predate migration — you
|
||||
can safely delete it and re-install the SurfSense Obsidian plugin to resume syncing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -111,7 +111,9 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
|
|||
: getConnectorTypeDisplay(connectorType)}
|
||||
</h2>
|
||||
<p className="text-xs sm:text-base text-muted-foreground mt-1">
|
||||
Enter your connection details
|
||||
{connectorType === "OBSIDIAN_CONNECTOR"
|
||||
? "Follow the plugin setup steps below"
|
||||
: "Enter your connection details"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -149,7 +151,9 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
|
|||
<span className={isSubmitting ? "opacity-0" : ""}>
|
||||
{connectorType === "MCP_CONNECTOR"
|
||||
? "Connect"
|
||||
: `Connect ${getConnectorTypeDisplay(connectorType)}`}
|
||||
: connectorType === "OBSIDIAN_CONNECTOR"
|
||||
? "Done"
|
||||
: `Connect ${getConnectorTypeDisplay(connectorType)}`}
|
||||
</span>
|
||||
{isSubmitting && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { ArrowLeft, Info, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { ArrowLeft, Info, RefreshCw } from "lucide-react";
|
||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
|
@ -18,7 +19,15 @@ import { VisionLLMConfig } from "../../components/vision-llm-config";
|
|||
import { getReauthEndpoint, LIVE_CONNECTOR_TYPES } from "../../constants/connector-constants";
|
||||
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||
import { MCPServiceConfig } from "../components/mcp-service-config";
|
||||
import { type ConnectorConfigProps, getConnectorConfigComponent } from "../index";
|
||||
import { getConnectorConfigComponent } from "../index";
|
||||
|
||||
const VISION_LLM_CONNECTOR_TYPES = new Set<SearchSourceConnector["connector_type"]>([
|
||||
EnumConnectorName.GOOGLE_DRIVE_CONNECTOR,
|
||||
EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
||||
EnumConnectorName.DROPBOX_CONNECTOR,
|
||||
EnumConnectorName.ONEDRIVE_CONNECTOR,
|
||||
EnumConnectorName.OBSIDIAN_CONNECTOR,
|
||||
]);
|
||||
|
||||
interface ConnectorEditViewProps {
|
||||
connector: SearchSourceConnector;
|
||||
|
|
@ -75,6 +84,9 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
const isAuthExpired = connector.config?.auth_expired === true;
|
||||
const reauthEndpoint = getReauthEndpoint(connector);
|
||||
const [reauthing, setReauthing] = useState(false);
|
||||
const supportsVisionLlm = VISION_LLM_CONNECTOR_TYPES.has(connector.connector_type);
|
||||
const showsAiToggles =
|
||||
connector.is_indexable || connector.connector_type === EnumConnectorName.OBSIDIAN_CONNECTOR;
|
||||
|
||||
const handleReauth = useCallback(async () => {
|
||||
const spaceId = searchSpaceId ?? searchSpaceIdAtom;
|
||||
|
|
@ -264,25 +276,23 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Summary and sync settings - hidden for live connectors */}
|
||||
{connector.is_indexable && !isLive && (
|
||||
{/* Summary + vision toggles (Obsidian is plugin-push, non-indexable by design) */}
|
||||
{showsAiToggles && !isLive && (
|
||||
<>
|
||||
{/* AI Summary toggle */}
|
||||
<SummaryConfig enabled={enableSummary} onEnabledChange={onEnableSummaryChange} />
|
||||
|
||||
{/* Vision LLM toggle - only for file-based connectors */}
|
||||
{(connector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
|
||||
connector.connector_type === "DROPBOX_CONNECTOR" ||
|
||||
connector.connector_type === "ONEDRIVE_CONNECTOR") && (
|
||||
{/* Vision LLM toggle for file/attachment connectors */}
|
||||
{supportsVisionLlm && (
|
||||
<VisionLLMConfig
|
||||
enabled={enableVisionLlm}
|
||||
onEnabledChange={onEnableVisionLlmChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, GitHub, or Local Folder */}
|
||||
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
{/* Date-range and periodic sync stay indexable-only */}
|
||||
{connector.is_indexable &&
|
||||
connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
connector.connector_type !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
connector.connector_type !== "DROPBOX_CONNECTOR" &&
|
||||
connector.connector_type !== "ONEDRIVE_CONNECTOR" &&
|
||||
|
|
@ -302,37 +312,40 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
|
||||
const isComposioGoogleDrive =
|
||||
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
|
||||
const requiresFolderSelection = isGoogleDrive || isComposioGoogleDrive;
|
||||
const selectedFolders =
|
||||
(connector.config?.selected_folders as
|
||||
| Array<{ id: string; name: string }>
|
||||
| undefined) || [];
|
||||
const selectedFiles =
|
||||
(connector.config?.selected_files as
|
||||
| Array<{ id: string; name: string }>
|
||||
| undefined) || [];
|
||||
const hasItemsSelected = selectedFolders.length > 0 || selectedFiles.length > 0;
|
||||
const isDisabled = requiresFolderSelection && !hasItemsSelected;
|
||||
{connector.is_indexable &&
|
||||
(() => {
|
||||
const isGoogleDrive =
|
||||
connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
|
||||
const isComposioGoogleDrive =
|
||||
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
|
||||
const requiresFolderSelection = isGoogleDrive || isComposioGoogleDrive;
|
||||
const selectedFolders =
|
||||
(connector.config?.selected_folders as
|
||||
| Array<{ id: string; name: string }>
|
||||
| undefined) || [];
|
||||
const selectedFiles =
|
||||
(connector.config?.selected_files as
|
||||
| Array<{ id: string; name: string }>
|
||||
| undefined) || [];
|
||||
const hasItemsSelected =
|
||||
selectedFolders.length > 0 || selectedFiles.length > 0;
|
||||
const isDisabled = requiresFolderSelection && !hasItemsSelected;
|
||||
|
||||
return (
|
||||
<PeriodicSyncConfig
|
||||
enabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
onEnabledChange={onPeriodicEnabledChange}
|
||||
onFrequencyChange={onFrequencyChange}
|
||||
disabled={isDisabled}
|
||||
disabledMessage={
|
||||
isDisabled
|
||||
? "Select at least one folder or file above to enable periodic sync"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
return (
|
||||
<PeriodicSyncConfig
|
||||
enabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
onEnabledChange={onPeriodicEnabledChange}
|
||||
onFrequencyChange={onFrequencyChange}
|
||||
disabled={isDisabled}
|
||||
disabledMessage={
|
||||
isDisabled
|
||||
? "Select at least one folder or file above to enable periodic sync"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
@ -403,7 +416,6 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
disabled={isSaving || isDisconnecting}
|
||||
className="text-xs sm:text-sm flex-1 sm:flex-initial h-12 sm:h-auto py-3 sm:py-2"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { ArrowLeft, Check, Info } from "lucide-react";
|
|||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -18,6 +19,14 @@ import {
|
|||
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||
import { getConnectorConfigComponent } from "../index";
|
||||
|
||||
const VISION_LLM_CONNECTOR_TYPES = new Set<string>([
|
||||
"GOOGLE_DRIVE_CONNECTOR",
|
||||
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||
"DROPBOX_CONNECTOR",
|
||||
"ONEDRIVE_CONNECTOR",
|
||||
"OBSIDIAN_CONNECTOR",
|
||||
]);
|
||||
|
||||
interface IndexingConfigurationViewProps {
|
||||
config: IndexingConfigState;
|
||||
connector?: SearchSourceConnector;
|
||||
|
|
@ -68,6 +77,9 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
() => (connector ? getConnectorConfigComponent(connector.connector_type) : null),
|
||||
[connector]
|
||||
);
|
||||
const showsAiToggles =
|
||||
(connector?.is_indexable ?? false) ||
|
||||
connector?.connector_type === EnumConnectorName.OBSIDIAN_CONNECTOR;
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [hasMoreContent, setHasMoreContent] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -164,25 +176,23 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
<ConnectorConfigComponent connector={connector} onConfigChange={onConfigChange} />
|
||||
)}
|
||||
|
||||
{/* Summary and sync settings - hidden for live connectors */}
|
||||
{connector?.is_indexable && !isLive && (
|
||||
{/* Summary + vision toggles (Obsidian is plugin-push, non-indexable by design) */}
|
||||
{showsAiToggles && !isLive && (
|
||||
<>
|
||||
{/* AI Summary toggle */}
|
||||
<SummaryConfig enabled={enableSummary} onEnabledChange={onEnableSummaryChange} />
|
||||
|
||||
{/* Vision LLM toggle - only for file-based connectors */}
|
||||
{(config.connectorType === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
config.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" ||
|
||||
config.connectorType === "DROPBOX_CONNECTOR" ||
|
||||
config.connectorType === "ONEDRIVE_CONNECTOR") && (
|
||||
{/* Vision LLM toggle for file/attachment connectors */}
|
||||
{VISION_LLM_CONNECTOR_TYPES.has(config.connectorType) && (
|
||||
<VisionLLMConfig
|
||||
enabled={enableVisionLlm}
|
||||
onEnabledChange={onEnableVisionLlmChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Date range selector - not shown for file-based connectors (Drive, Dropbox, OneDrive), Webcrawler, GitHub, or Local Folder */}
|
||||
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
{/* Date-range and periodic sync stay indexable-only */}
|
||||
{connector?.is_indexable &&
|
||||
config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "DROPBOX_CONNECTOR" &&
|
||||
config.connectorType !== "ONEDRIVE_CONNECTOR" &&
|
||||
|
|
@ -202,7 +212,8 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
{connector?.is_indexable &&
|
||||
config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "DROPBOX_CONNECTOR" &&
|
||||
config.connectorType !== "ONEDRIVE_CONNECTOR" && (
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ export const OTHER_CONNECTORS = [
|
|||
{
|
||||
id: "obsidian-connector",
|
||||
title: "Obsidian",
|
||||
description: "Index your Obsidian vault (Local folder scan on Desktop)",
|
||||
description: "Sync your Obsidian vault on desktop or mobile",
|
||||
connectorType: EnumConnectorName.OBSIDIAN_CONNECTOR,
|
||||
},
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { format } from "date-fns";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
|
|
@ -10,17 +10,11 @@ import {
|
|||
updateConnectorMutationAtom,
|
||||
} from "@/atoms/connectors/connector-mutation.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import {
|
||||
folderWatchDialogOpenAtom,
|
||||
folderWatchInitialFolderAtom,
|
||||
} from "@/atoms/folder-sync/folder-sync.atoms";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { searchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { usePlatform } from "@/hooks/use-platform";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { isSelfHosted } from "@/lib/env-config";
|
||||
import {
|
||||
trackConnectorConnected,
|
||||
trackConnectorDeleted,
|
||||
|
|
@ -71,10 +65,6 @@ export const useConnectorDialog = () => {
|
|||
const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom);
|
||||
const { mutateAsync: deleteConnector } = useAtomValue(deleteConnectorMutationAtom);
|
||||
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
|
||||
const setFolderWatchOpen = useSetAtom(folderWatchDialogOpenAtom);
|
||||
const setFolderWatchInitialFolder = useSetAtom(folderWatchInitialFolderAtom);
|
||||
const { isDesktop } = usePlatform();
|
||||
const selfHosted = isSelfHosted();
|
||||
|
||||
// Use global atom for dialog open state so it can be controlled from anywhere
|
||||
const [isOpen, setIsOpen] = useAtom(connectorDialogOpenAtom);
|
||||
|
|
@ -439,6 +429,7 @@ export const useConnectorDialog = () => {
|
|||
indexing_frequency_minutes: null,
|
||||
next_scheduled_at: null,
|
||||
enable_summary: false,
|
||||
enable_vision_llm: false,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
|
|
@ -487,31 +478,16 @@ export const useConnectorDialog = () => {
|
|||
}
|
||||
}, [searchSpaceId, createConnector, refetchAllConnectors, setIsOpen]);
|
||||
|
||||
// Handle connecting non-OAuth connectors (like Tavily API)
|
||||
// Handle connecting non-OAuth connectors (like Tavily API, Obsidian plugin, etc.)
|
||||
const handleConnectNonOAuth = useCallback(
|
||||
(connectorType: string) => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
trackConnectorSetupStarted(Number(searchSpaceId), connectorType, "non_oauth_click");
|
||||
|
||||
// Handle Obsidian specifically on Desktop & Cloud
|
||||
if (connectorType === EnumConnectorName.OBSIDIAN_CONNECTOR && !selfHosted && isDesktop) {
|
||||
setIsOpen(false);
|
||||
setFolderWatchInitialFolder(null);
|
||||
setFolderWatchOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setConnectingConnectorType(connectorType);
|
||||
},
|
||||
[
|
||||
searchSpaceId,
|
||||
selfHosted,
|
||||
isDesktop,
|
||||
setIsOpen,
|
||||
setFolderWatchOpen,
|
||||
setFolderWatchInitialFolder,
|
||||
]
|
||||
[searchSpaceId]
|
||||
);
|
||||
|
||||
// Handle submitting connect form
|
||||
|
|
@ -555,6 +531,7 @@ export const useConnectorDialog = () => {
|
|||
is_active: true,
|
||||
next_scheduled_at: connectorData.next_scheduled_at as string | null,
|
||||
enable_summary: false,
|
||||
enable_vision_llm: false,
|
||||
},
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
|
|
|
|||
|
|
@ -229,6 +229,44 @@ function extractDomain(url: string): string {
|
|||
// Canonical local-file virtual paths are mount-prefixed: /<mount>/<relative/path>
|
||||
const LOCAL_FILE_PATH_REGEX = /^\/[a-z0-9_-]+\/[^\s`]+(?:\/[^\s`]+)*$/;
|
||||
|
||||
type AgentFilesystemMount = {
|
||||
mount: string;
|
||||
rootPath: string;
|
||||
};
|
||||
|
||||
function normalizeLocalVirtualPathForEditor(
|
||||
candidatePath: string,
|
||||
mounts: AgentFilesystemMount[]
|
||||
): string {
|
||||
const normalizedCandidate = candidatePath.trim().replace(/\\/g, "/").replace(/\/+/g, "/");
|
||||
if (!normalizedCandidate) {
|
||||
return candidatePath;
|
||||
}
|
||||
const defaultMount = mounts[0]?.mount;
|
||||
if (!defaultMount) {
|
||||
return normalizedCandidate.startsWith("/")
|
||||
? normalizedCandidate
|
||||
: `/${normalizedCandidate.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
const mountNames = new Set(mounts.map((entry) => entry.mount));
|
||||
if (normalizedCandidate.startsWith("/")) {
|
||||
const relative = normalizedCandidate.replace(/^\/+/, "");
|
||||
const [firstSegment] = relative.split("/", 1);
|
||||
if (mountNames.has(firstSegment)) {
|
||||
return `/${relative}`;
|
||||
}
|
||||
return `/${defaultMount}/${relative}`;
|
||||
}
|
||||
|
||||
const relative = normalizedCandidate.replace(/^\/+/, "");
|
||||
const [firstSegment] = relative.split("/", 1);
|
||||
if (mountNames.has(firstSegment)) {
|
||||
return `/${relative}`;
|
||||
}
|
||||
return `/${defaultMount}/${relative}`;
|
||||
}
|
||||
|
||||
function isVirtualFilePathToken(value: string): boolean {
|
||||
if (!LOCAL_FILE_PATH_REGEX.test(value) || value.startsWith("//")) {
|
||||
return false;
|
||||
|
|
@ -421,8 +459,15 @@ const defaultComponents = memoizeMarkdownComponents({
|
|||
!codeString.includes("\n");
|
||||
if (!isCodeBlock) {
|
||||
const inlineValue = String(children ?? "").trim();
|
||||
const normalizedInlinePath = inlineValue.replace(/\/+$/, "");
|
||||
const leafSegment = normalizedInlinePath.split("/").filter(Boolean).at(-1) ?? "";
|
||||
const isLikelyFolder =
|
||||
inlineValue.endsWith("/") || !leafSegment || !leafSegment.includes(".");
|
||||
const isLocalPath =
|
||||
!!electronAPI && isVirtualFilePathToken(inlineValue) && !inlineValue.startsWith("//");
|
||||
!!electronAPI &&
|
||||
isVirtualFilePathToken(inlineValue) &&
|
||||
!inlineValue.startsWith("//") &&
|
||||
!isLikelyFolder;
|
||||
const displayLocalPath = inlineValue.replace(/^\/+/, "");
|
||||
const searchSpaceIdParam = params?.search_space_id;
|
||||
const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam)
|
||||
|
|
@ -438,14 +483,31 @@ const defaultComponents = memoizeMarkdownComponents({
|
|||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openEditorPanel({
|
||||
kind: "local_file",
|
||||
localFilePath: inlineValue,
|
||||
title: inlineValue.split("/").pop() || inlineValue,
|
||||
searchSpaceId: Number.isFinite(parsedSearchSpaceId)
|
||||
void (async () => {
|
||||
let resolvedLocalPath = inlineValue;
|
||||
const resolvedSearchSpaceId = Number.isFinite(parsedSearchSpaceId)
|
||||
? parsedSearchSpaceId
|
||||
: undefined,
|
||||
});
|
||||
: undefined;
|
||||
if (electronAPI?.getAgentFilesystemMounts) {
|
||||
try {
|
||||
const mounts = (await electronAPI.getAgentFilesystemMounts(
|
||||
resolvedSearchSpaceId
|
||||
)) as AgentFilesystemMount[];
|
||||
resolvedLocalPath = normalizeLocalVirtualPathForEditor(
|
||||
inlineValue,
|
||||
mounts
|
||||
);
|
||||
} catch {
|
||||
// Fall back to the raw inline path if mount lookup fails.
|
||||
}
|
||||
}
|
||||
openEditorPanel({
|
||||
kind: "local_file",
|
||||
localFilePath: resolvedLocalPath,
|
||||
title: resolvedLocalPath.split("/").pop() || resolvedLocalPath,
|
||||
searchSpaceId: resolvedSearchSpaceId,
|
||||
});
|
||||
})();
|
||||
}}
|
||||
title="Open in editor panel"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { MessageCircleReply } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { clearTargetCommentIdAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
|
|
@ -216,7 +216,7 @@ export function CommentItem({
|
|||
className="mt-1 h-7 w-fit px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onReply(comment.id)}
|
||||
>
|
||||
<MessageSquare className="mr-1 size-3" />
|
||||
<MessageCircleReply className="mr-1 size-3" />
|
||||
Reply
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { MessageCircleReply } from "lucide-react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
|
|
@ -30,7 +30,7 @@ export function CommentSheet({
|
|||
<DrawerHandle />
|
||||
<DrawerHeader className="px-4 pb-3 pt-2">
|
||||
<DrawerTitle className="flex items-center gap-2 text-base font-semibold">
|
||||
<MessageSquare className="size-5" />
|
||||
<MessageCircleReply className="size-5" />
|
||||
Comments
|
||||
{commentCount > 0 && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
|
|
@ -56,7 +56,7 @@ export function CommentSheet({
|
|||
>
|
||||
<SheetHeader className="flex-shrink-0 px-4 py-4">
|
||||
<SheetTitle className="flex items-center gap-2 text-base font-semibold">
|
||||
<MessageSquare className="size-5" />
|
||||
<MessageCircleReply className="size-5" />
|
||||
Comments
|
||||
{commentCount > 0 && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, MessageSquare } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, MessageCircleReply } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CommentComposer } from "../comment-composer/comment-composer";
|
||||
|
|
@ -143,7 +143,7 @@ export function CommentThread({
|
|||
</div>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
|
||||
<MessageSquare className="mr-1 size-3" />
|
||||
<MessageCircleReply className="mr-1 size-3" />
|
||||
Reply
|
||||
</Button>
|
||||
)}
|
||||
|
|
@ -155,7 +155,7 @@ export function CommentThread({
|
|||
{!hasReplies && !isReplyComposerOpen && (
|
||||
<div className="ml-7 mt-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
|
||||
<MessageSquare className="mr-1 size-3" />
|
||||
<MessageCircleReply className="mr-1 size-3" />
|
||||
Reply
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export function DocumentsFilters({
|
|||
<TooltipTrigger asChild>
|
||||
<ToggleGroupItem
|
||||
value="folder"
|
||||
className="h-9 w-9 shrink-0 border-sidebar-border text-muted-foreground hover:text-foreground hover:border-sidebar-border bg-sidebar"
|
||||
className="h-9 w-9 shrink-0 border bg-muted/50 text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onCreateFolder();
|
||||
|
|
@ -104,11 +104,11 @@ export function DocumentsFilters({
|
|||
value="ai-sort"
|
||||
disabled={aiSortBusy}
|
||||
className={cn(
|
||||
"h-9 w-9 shrink-0 border-sidebar-border bg-sidebar",
|
||||
"h-9 w-9 shrink-0 border bg-muted/50 transition-colors",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
aiSortEnabled
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:border-sidebar-border"
|
||||
? "bg-accent text-accent-foreground hover:bg-accent"
|
||||
: "text-muted-foreground hover:bg-muted/80 hover:text-foreground"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -142,11 +142,11 @@ export function DocumentsFilters({
|
|||
<PopoverTrigger asChild>
|
||||
<ToggleGroupItem
|
||||
value="filter"
|
||||
className="relative h-9 w-9 shrink-0 border-sidebar-border text-muted-foreground hover:text-foreground hover:border-sidebar-border bg-sidebar overflow-visible"
|
||||
className="relative h-9 w-9 shrink-0 border bg-muted/50 text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground overflow-visible"
|
||||
>
|
||||
<ListFilter size={14} />
|
||||
{activeTypes.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-sidebar-border text-[9px] font-medium text-sidebar-foreground">
|
||||
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-neutral-300 text-[9px] font-medium text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200">
|
||||
{activeTypes.length}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -226,13 +226,13 @@ export function DocumentsFilters({
|
|||
|
||||
{/* Search Input */}
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<Search size={14} aria-hidden="true" />
|
||||
</div>
|
||||
<Input
|
||||
id={`${id}-input`}
|
||||
ref={inputRef}
|
||||
className="peer h-9 w-full pl-9 pr-9 text-sm bg-sidebar border-border/60 select-none focus:select-text"
|
||||
className="h-9 w-full pl-9 pr-8 text-sm select-none focus:select-text"
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
placeholder="Search docs"
|
||||
|
|
@ -242,7 +242,7 @@ export function DocumentsFilters({
|
|||
{Boolean(searchValue) && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex h-full w-9 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 inline-flex h-6 w-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
aria-label="Clear filter"
|
||||
onClick={() => {
|
||||
onSearch("");
|
||||
|
|
@ -260,7 +260,7 @@ export function DocumentsFilters({
|
|||
onClick={handleUpload}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 shrink-0 gap-1.5 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
|
||||
className="h-9 shrink-0 gap-1.5 border-0 shadow-none bg-white text-gray-700 hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
|
||||
>
|
||||
<Upload size={14} />
|
||||
<span>Upload</span>
|
||||
|
|
|
|||
|
|
@ -47,6 +47,42 @@ interface EditorContent {
|
|||
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
|
||||
type EditorRenderMode = "rich_markdown" | "source_code";
|
||||
|
||||
type AgentFilesystemMount = {
|
||||
mount: string;
|
||||
rootPath: string;
|
||||
};
|
||||
|
||||
function normalizeLocalVirtualPathForEditor(
|
||||
candidatePath: string,
|
||||
mounts: AgentFilesystemMount[]
|
||||
): string {
|
||||
const normalizedCandidate = candidatePath.trim().replace(/\\/g, "/").replace(/\/+/g, "/");
|
||||
if (!normalizedCandidate) return candidatePath;
|
||||
const defaultMount = mounts[0]?.mount;
|
||||
if (!defaultMount) {
|
||||
return normalizedCandidate.startsWith("/")
|
||||
? normalizedCandidate
|
||||
: `/${normalizedCandidate.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
const mountNames = new Set(mounts.map((entry) => entry.mount));
|
||||
if (normalizedCandidate.startsWith("/")) {
|
||||
const relative = normalizedCandidate.replace(/^\/+/, "");
|
||||
const [firstSegment] = relative.split("/", 1);
|
||||
if (mountNames.has(firstSegment)) {
|
||||
return `/${relative}`;
|
||||
}
|
||||
return `/${defaultMount}/${relative}`;
|
||||
}
|
||||
|
||||
const relative = normalizedCandidate.replace(/^\/+/, "");
|
||||
const [firstSegment] = relative.split("/", 1);
|
||||
if (mountNames.has(firstSegment)) {
|
||||
return `/${relative}`;
|
||||
}
|
||||
return `/${defaultMount}/${relative}`;
|
||||
}
|
||||
|
||||
function EditorPanelSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
|
|
@ -100,6 +136,22 @@ export function EditorPanelContent({
|
|||
const [displayTitle, setDisplayTitle] = useState(title || "Untitled");
|
||||
const isLocalFileMode = kind === "local_file";
|
||||
const editorRenderMode: EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown";
|
||||
const resolveLocalVirtualPath = useCallback(
|
||||
async (candidatePath: string): Promise<string> => {
|
||||
if (!electronAPI?.getAgentFilesystemMounts) {
|
||||
return candidatePath;
|
||||
}
|
||||
try {
|
||||
const mounts = (await electronAPI.getAgentFilesystemMounts(
|
||||
searchSpaceId
|
||||
)) as AgentFilesystemMount[];
|
||||
return normalizeLocalVirtualPathForEditor(candidatePath, mounts);
|
||||
} catch {
|
||||
return candidatePath;
|
||||
}
|
||||
},
|
||||
[electronAPI, searchSpaceId]
|
||||
);
|
||||
|
||||
const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD;
|
||||
|
||||
|
|
@ -124,11 +176,15 @@ export function EditorPanelContent({
|
|||
if (!electronAPI?.readAgentLocalFileText) {
|
||||
throw new Error("Local file editor is available only in desktop mode.");
|
||||
}
|
||||
const readResult = await electronAPI.readAgentLocalFileText(localFilePath);
|
||||
const resolvedLocalPath = await resolveLocalVirtualPath(localFilePath);
|
||||
const readResult = await electronAPI.readAgentLocalFileText(
|
||||
resolvedLocalPath,
|
||||
searchSpaceId
|
||||
);
|
||||
if (!readResult.ok) {
|
||||
throw new Error(readResult.error || "Failed to read local file");
|
||||
}
|
||||
const inferredTitle = localFilePath.split("/").pop() || localFilePath;
|
||||
const inferredTitle = resolvedLocalPath.split("/").pop() || resolvedLocalPath;
|
||||
const content: EditorContent = {
|
||||
document_id: -1,
|
||||
title: inferredTitle,
|
||||
|
|
@ -192,7 +248,15 @@ export function EditorPanelContent({
|
|||
|
||||
doFetch().catch(() => {});
|
||||
return () => controller.abort();
|
||||
}, [documentId, electronAPI, isLocalFileMode, localFilePath, searchSpaceId, title]);
|
||||
}, [
|
||||
documentId,
|
||||
electronAPI,
|
||||
isLocalFileMode,
|
||||
localFilePath,
|
||||
resolveLocalVirtualPath,
|
||||
searchSpaceId,
|
||||
title,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
|
@ -227,7 +291,7 @@ export function EditorPanelContent({
|
|||
}, [editorDoc?.source_markdown]);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (options?: { silent?: boolean }) => {
|
||||
async (_options?: { silent?: boolean }) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isLocalFileMode) {
|
||||
|
|
@ -237,10 +301,12 @@ export function EditorPanelContent({
|
|||
if (!electronAPI?.writeAgentLocalFileText) {
|
||||
throw new Error("Local file editor is available only in desktop mode.");
|
||||
}
|
||||
const resolvedLocalPath = await resolveLocalVirtualPath(localFilePath);
|
||||
const contentToSave = markdownRef.current;
|
||||
const writeResult = await electronAPI.writeAgentLocalFileText(
|
||||
localFilePath,
|
||||
contentToSave
|
||||
resolvedLocalPath,
|
||||
contentToSave,
|
||||
searchSpaceId
|
||||
);
|
||||
if (!writeResult.ok) {
|
||||
throw new Error(writeResult.error || "Failed to save local file");
|
||||
|
|
@ -286,7 +352,14 @@ export function EditorPanelContent({
|
|||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[documentId, electronAPI, isLocalFileMode, localFilePath, searchSpaceId]
|
||||
[
|
||||
documentId,
|
||||
electronAPI,
|
||||
isLocalFileMode,
|
||||
localFilePath,
|
||||
resolveLocalVirtualPath,
|
||||
searchSpaceId,
|
||||
]
|
||||
);
|
||||
|
||||
const isEditableType = editorDoc
|
||||
|
|
@ -322,7 +395,7 @@ export function EditorPanelContent({
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex h-10 items-center justify-between gap-2 border-t px-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0 flex flex-1 items-center gap-2">
|
||||
<p className="truncate text-sm text-muted-foreground">{displayTitle}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
|
|
@ -353,6 +426,12 @@ export function EditorPanelContent({
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
{!isLocalFileMode && editorDoc?.document_type && documentId && (
|
||||
<VersionHistoryButton
|
||||
documentId={documentId}
|
||||
documentType={editorDoc.document_type}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -384,21 +463,12 @@ export function EditorPanelContent({
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{!showEditingActions &&
|
||||
!isLocalFileMode &&
|
||||
editorDoc?.document_type &&
|
||||
documentId && (
|
||||
<VersionHistoryButton
|
||||
documentId={documentId}
|
||||
documentType={editorDoc.document_type}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-14 items-center justify-between border-b px-4 shrink-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-1 min-w-0 items-center gap-2">
|
||||
<h2 className="text-sm font-semibold truncate">{displayTitle}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
|
|
@ -429,6 +499,12 @@ export function EditorPanelContent({
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
{!isLocalFileMode && editorDoc?.document_type && documentId && (
|
||||
<VersionHistoryButton
|
||||
documentId={documentId}
|
||||
documentType={editorDoc.document_type}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -458,12 +534,6 @@ export function EditorPanelContent({
|
|||
<span className="sr-only">Edit document</span>
|
||||
</Button>
|
||||
)}
|
||||
{!isLocalFileMode && editorDoc?.document_type && documentId && (
|
||||
<VersionHistoryButton
|
||||
documentId={documentId}
|
||||
documentType={editorDoc.document_type}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -508,7 +578,7 @@ export function EditorPanelContent({
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 gap-1.5"
|
||||
className="relative shrink-0"
|
||||
disabled={downloading}
|
||||
onClick={async () => {
|
||||
setDownloading(true);
|
||||
|
|
@ -540,8 +610,11 @@ export function EditorPanelContent({
|
|||
}
|
||||
}}
|
||||
>
|
||||
{downloading ? <Spinner size="xs" /> : <Download className="size-3.5" />}
|
||||
{downloading ? "Preparing..." : "Download .md"}
|
||||
<span className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}>
|
||||
<Download className="size-3.5" />
|
||||
Download .md
|
||||
</span>
|
||||
{downloading && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -114,10 +114,10 @@ export function SourceCodeEditor({
|
|||
automaticLayout: true,
|
||||
minimap: { enabled: false },
|
||||
lineNumbers: "on",
|
||||
lineNumbersMinChars: 3,
|
||||
lineDecorationsWidth: 12,
|
||||
lineNumbersMinChars: 4,
|
||||
lineDecorationsWidth: 20,
|
||||
glyphMargin: false,
|
||||
folding: true,
|
||||
folding: false,
|
||||
overviewRulerLanes: 0,
|
||||
hideCursorInOverviewRuler: true,
|
||||
scrollBeyondLastLine: false,
|
||||
|
|
@ -142,7 +142,17 @@ export function SourceCodeEditor({
|
|||
fontSize,
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace",
|
||||
renderWhitespace: "selection",
|
||||
renderWhitespace: "none",
|
||||
renderValidationDecorations: "off",
|
||||
colorDecorators: false,
|
||||
codeLens: false,
|
||||
hover: { enabled: false },
|
||||
stickyScroll: { enabled: false },
|
||||
unicodeHighlight: {
|
||||
ambiguousCharacters: false,
|
||||
invisibleCharacters: false,
|
||||
nonBasicASCII: false,
|
||||
},
|
||||
smoothScrolling: true,
|
||||
readOnly,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
"use client";
|
||||
|
||||
import { Folder, FolderPlus, Search, X } from "lucide-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { localExpandedFolderKeysAtom } from "@/atoms/documents/folder.atoms";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { LocalFilesystemBrowser } from "./LocalFilesystemBrowser";
|
||||
|
||||
const getFolderDisplayName = (rootPath: string): string =>
|
||||
rootPath.split(/[\\/]/).at(-1) || rootPath;
|
||||
|
||||
interface DesktopLocalTabContentProps {
|
||||
localRootPaths: string[];
|
||||
canAddMoreLocalRoots: boolean;
|
||||
maxLocalFilesystemRoots: number;
|
||||
searchSpaceId: number;
|
||||
onPickFilesystemRoot: () => Promise<void> | void;
|
||||
onRemoveFilesystemRoot: (rootPath: string) => Promise<void> | void;
|
||||
onClearFilesystemRoots: () => Promise<void> | void;
|
||||
onOpenLocalFile: (localFilePath: string) => void;
|
||||
electronAvailable: boolean;
|
||||
}
|
||||
|
||||
export function DesktopLocalTabContent({
|
||||
localRootPaths,
|
||||
canAddMoreLocalRoots,
|
||||
maxLocalFilesystemRoots,
|
||||
searchSpaceId,
|
||||
onPickFilesystemRoot,
|
||||
onRemoveFilesystemRoot,
|
||||
onClearFilesystemRoots,
|
||||
onOpenLocalFile,
|
||||
electronAvailable,
|
||||
}: DesktopLocalTabContentProps) {
|
||||
const [localSearch, setLocalSearch] = useState("");
|
||||
const debouncedLocalSearch = useDebouncedValue(localSearch, 250);
|
||||
const localSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [expandedFolderKeyMap, setExpandedFolderKeyMap] = useAtom(localExpandedFolderKeysAtom);
|
||||
const expandedFolderKeys = useMemo(
|
||||
() => new Set(expandedFolderKeyMap[searchSpaceId] ?? []),
|
||||
[expandedFolderKeyMap, searchSpaceId]
|
||||
);
|
||||
const handleExpandedFolderKeysChange = useCallback(
|
||||
(nextExpandedKeys: Set<string>) => {
|
||||
setExpandedFolderKeyMap((prev) => ({
|
||||
...prev,
|
||||
[searchSpaceId]: Array.from(nextExpandedKeys),
|
||||
}));
|
||||
},
|
||||
[searchSpaceId, setExpandedFolderKeyMap]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col select-none">
|
||||
<div className="mx-4 mt-4 mb-3">
|
||||
<div className="flex h-7 w-full items-stretch rounded-lg border bg-muted/50 text-[11px] text-muted-foreground">
|
||||
{localRootPaths.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 flex items-center gap-1 rounded-l-lg px-2 text-left transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
title={localRootPaths.join("\n")}
|
||||
aria-label="Manage selected folders"
|
||||
>
|
||||
<Folder className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{localRootPaths.length === 1
|
||||
? "1 folder selected"
|
||||
: `${localRootPaths.length} folders selected`}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56 select-none p-0.5">
|
||||
<DropdownMenuLabel className="px-1.5 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">
|
||||
Selected folders
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="mx-1 my-0.5" />
|
||||
{localRootPaths.map((rootPath) => (
|
||||
<DropdownMenuItem
|
||||
key={rootPath}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
className="group h-8 gap-1.5 px-1.5 text-sm text-foreground"
|
||||
>
|
||||
<Folder className="size-3.5 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{getFolderDisplayName(rootPath)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void onRemoveFilesystemRoot(rootPath);
|
||||
}}
|
||||
aria-label={`Remove ${getFolderDisplayName(rootPath)}`}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator className="mx-1 my-0.5" />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="h-8 px-1.5 text-xs text-destructive focus:text-destructive"
|
||||
onClick={() => {
|
||||
void onClearFilesystemRoots();
|
||||
}}
|
||||
>
|
||||
Clear all folders
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<div
|
||||
className="min-w-0 flex-1 flex items-center gap-1 px-2"
|
||||
title="No local folders selected"
|
||||
>
|
||||
<Folder className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">No local folders selected</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="data-[orientation=vertical]:h-3 self-center bg-border"
|
||||
/>
|
||||
{electronAvailable ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-8 items-center justify-center rounded-r-lg text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:opacity-50"
|
||||
onClick={() => {
|
||||
void onPickFilesystemRoot();
|
||||
}}
|
||||
disabled={!canAddMoreLocalRoots}
|
||||
aria-label="Add folder"
|
||||
>
|
||||
<FolderPlus className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{canAddMoreLocalRoots
|
||||
? "Add folder"
|
||||
: `You can add up to ${maxLocalFilesystemRoots} folders`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-4 mb-2">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
|
||||
<Search size={13} aria-hidden="true" />
|
||||
</div>
|
||||
<Input
|
||||
ref={localSearchInputRef}
|
||||
className="peer h-8 w-full pl-8 pr-8 text-sm bg-sidebar border-border/60 select-none focus:select-text"
|
||||
value={localSearch}
|
||||
onChange={(e) => setLocalSearch(e.target.value)}
|
||||
placeholder="Search local files"
|
||||
type="text"
|
||||
aria-label="Search local files"
|
||||
/>
|
||||
{Boolean(localSearch) && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex h-full w-8 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Clear local search"
|
||||
onClick={() => {
|
||||
setLocalSearch("");
|
||||
localSearchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<X size={13} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<LocalFilesystemBrowser
|
||||
rootPaths={localRootPaths}
|
||||
searchSpaceId={searchSpaceId}
|
||||
active
|
||||
searchQuery={debouncedLocalSearch.trim() || undefined}
|
||||
onOpenFile={onOpenLocalFile}
|
||||
expandedFolderKeys={expandedFolderKeys}
|
||||
onExpandedFolderKeysChange={handleExpandedFolderKeysChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,19 +6,17 @@ import {
|
|||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderClock,
|
||||
FolderPlus,
|
||||
Laptop,
|
||||
Lock,
|
||||
Paperclip,
|
||||
Search,
|
||||
Server,
|
||||
Trash2,
|
||||
Unplug,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
|
@ -49,7 +47,6 @@ import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
|
|||
import {
|
||||
DEFAULT_EXCLUDE_PATTERNS,
|
||||
FolderWatchDialog,
|
||||
type SelectedFolder,
|
||||
} from "@/components/sources/FolderWatchDialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
|
|
@ -64,16 +61,7 @@ import {
|
|||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -83,7 +71,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
|||
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
|
||||
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
||||
|
|
@ -92,12 +80,42 @@ import { authenticatedFetch } from "@/lib/auth-utils";
|
|||
import { uploadFolderScan } from "@/lib/folder-sync-upload";
|
||||
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
|
||||
import { queries } from "@/zero/queries/index";
|
||||
import { LocalFilesystemBrowser } from "./LocalFilesystemBrowser";
|
||||
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||
|
||||
const DesktopLocalTabContent = dynamic(
|
||||
() => import("./DesktopLocalTabContent").then((mod) => mod.DesktopLocalTabContent),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["SURFSENSE_DOCS"];
|
||||
const LOCAL_FILESYSTEM_TRUST_KEY = "surfsense.local-filesystem-trust.v1";
|
||||
const MAX_LOCAL_FILESYSTEM_ROOTS = 5;
|
||||
const MAX_LOCAL_FILESYSTEM_ROOTS = 10;
|
||||
|
||||
function CloudDocumentsSkeleton() {
|
||||
const rows = [
|
||||
{ id: "row-1", widthClass: "w-44" },
|
||||
{ id: "row-2", widthClass: "w-32" },
|
||||
{ id: "row-3", widthClass: "w-32" },
|
||||
{ id: "row-4", widthClass: "w-44" },
|
||||
{ id: "row-5", widthClass: "w-32" },
|
||||
{ id: "row-6", widthClass: "w-32" },
|
||||
{ id: "row-7", widthClass: "w-44" },
|
||||
{ id: "row-8", widthClass: "w-32" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-1">
|
||||
<div className="space-y-1">
|
||||
{rows.map((row) => (
|
||||
<div key={row.id} className="flex h-8 items-center gap-2 px-2">
|
||||
<Skeleton className="h-4 w-4 rounded-sm" />
|
||||
<Skeleton className={`h-4 ${row.widthClass}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FilesystemSettings = {
|
||||
mode: "cloud" | "desktop_local_folder";
|
||||
|
|
@ -115,9 +133,6 @@ interface WatchedFolderEntry {
|
|||
active: boolean;
|
||||
}
|
||||
|
||||
const getFolderDisplayName = (rootPath: string): string =>
|
||||
rootPath.split(/[\\/]/).at(-1) || rootPath;
|
||||
|
||||
const SHOWCASE_CONNECTORS = [
|
||||
{ type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" },
|
||||
{ type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" },
|
||||
|
|
@ -143,25 +158,40 @@ interface DocumentsSidebarProps {
|
|||
|
||||
export function DocumentsSidebar(props: DocumentsSidebarProps) {
|
||||
const isAnonymous = useIsAnonymous();
|
||||
const { isDesktop } = usePlatform();
|
||||
if (isAnonymous) {
|
||||
return <AnonymousDocumentsSidebar {...props} />;
|
||||
}
|
||||
return <AuthenticatedDocumentsSidebar {...props} />;
|
||||
return isDesktop ? (
|
||||
<AuthenticatedDesktopDocumentsSidebar {...props} />
|
||||
) : (
|
||||
<AuthenticatedWebDocumentsSidebar {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function AuthenticatedDocumentsSidebar({
|
||||
function AuthenticatedDesktopDocumentsSidebar(props: DocumentsSidebarProps) {
|
||||
return <AuthenticatedDocumentsSidebarBase {...props} desktopFeaturesEnabled />;
|
||||
}
|
||||
|
||||
function AuthenticatedWebDocumentsSidebar(props: DocumentsSidebarProps) {
|
||||
return <AuthenticatedDocumentsSidebarBase {...props} desktopFeaturesEnabled={false} />;
|
||||
}
|
||||
|
||||
function AuthenticatedDocumentsSidebarBase({
|
||||
open,
|
||||
onOpenChange,
|
||||
isDocked = false,
|
||||
onDockedChange,
|
||||
embedded = false,
|
||||
headerAction,
|
||||
}: DocumentsSidebarProps) {
|
||||
desktopFeaturesEnabled,
|
||||
}: DocumentsSidebarProps & { desktopFeaturesEnabled: boolean }) {
|
||||
const t = useTranslations("documents");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const params = useParams();
|
||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||
const electronAPI = useElectronAPI();
|
||||
const platformElectronAPI = useElectronAPI();
|
||||
const electronAPI = desktopFeaturesEnabled ? platformElectronAPI : null;
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
||||
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
|
||||
|
|
@ -171,9 +201,6 @@ function AuthenticatedDocumentsSidebar({
|
|||
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebouncedValue(search, 250);
|
||||
const [localSearch, setLocalSearch] = useState("");
|
||||
const debouncedLocalSearch = useDebouncedValue(localSearch, 250);
|
||||
const localSearchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
|
||||
const [filesystemSettings, setFilesystemSettings] = useState<FilesystemSettings | null>(null);
|
||||
const [localTrustDialogOpen, setLocalTrustDialogOpen] = useState(false);
|
||||
|
|
@ -181,13 +208,14 @@ function AuthenticatedDocumentsSidebar({
|
|||
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
|
||||
const [folderWatchOpen, setFolderWatchOpen] = useAtom(folderWatchDialogOpenAtom);
|
||||
const [watchInitialFolder, setWatchInitialFolder] = useAtom(folderWatchInitialFolderAtom);
|
||||
const isElectron = typeof window !== "undefined" && !!window.electronAPI;
|
||||
const isElectron =
|
||||
desktopFeaturesEnabled && typeof window !== "undefined" && !!window.electronAPI;
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronAPI?.getAgentFilesystemSettings) return;
|
||||
let mounted = true;
|
||||
electronAPI
|
||||
.getAgentFilesystemSettings()
|
||||
.getAgentFilesystemSettings(searchSpaceId)
|
||||
.then((settings: FilesystemSettings) => {
|
||||
if (!mounted) return;
|
||||
setFilesystemSettings(settings);
|
||||
|
|
@ -203,7 +231,7 @@ function AuthenticatedDocumentsSidebar({
|
|||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [electronAPI]);
|
||||
}, [electronAPI, searchSpaceId]);
|
||||
|
||||
const hasLocalFilesystemTrust = useCallback(() => {
|
||||
try {
|
||||
|
|
@ -219,17 +247,20 @@ function AuthenticatedDocumentsSidebar({
|
|||
const applyLocalRootPath = useCallback(
|
||||
async (path: string) => {
|
||||
if (!electronAPI?.setAgentFilesystemSettings) return;
|
||||
const nextLocalRootPaths = [...localRootPaths, path]
|
||||
const nextLocalRootPaths = [path, ...localRootPaths]
|
||||
.filter((rootPath, index, allPaths) => allPaths.indexOf(rootPath) === index)
|
||||
.slice(0, MAX_LOCAL_FILESYSTEM_ROOTS);
|
||||
if (nextLocalRootPaths.length === localRootPaths.length) return;
|
||||
const updated = await electronAPI.setAgentFilesystemSettings({
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: nextLocalRootPaths,
|
||||
});
|
||||
const updated = await electronAPI.setAgentFilesystemSettings(
|
||||
{
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: nextLocalRootPaths,
|
||||
},
|
||||
searchSpaceId
|
||||
);
|
||||
setFilesystemSettings(updated);
|
||||
},
|
||||
[electronAPI, localRootPaths]
|
||||
[electronAPI, localRootPaths, searchSpaceId]
|
||||
);
|
||||
|
||||
const runPickLocalRoot = useCallback(async () => {
|
||||
|
|
@ -255,33 +286,42 @@ function AuthenticatedDocumentsSidebar({
|
|||
const handleRemoveFilesystemRoot = useCallback(
|
||||
async (rootPathToRemove: string) => {
|
||||
if (!electronAPI?.setAgentFilesystemSettings) return;
|
||||
const updated = await electronAPI.setAgentFilesystemSettings({
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove),
|
||||
});
|
||||
const updated = await electronAPI.setAgentFilesystemSettings(
|
||||
{
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove),
|
||||
},
|
||||
searchSpaceId
|
||||
);
|
||||
setFilesystemSettings(updated);
|
||||
},
|
||||
[electronAPI, localRootPaths]
|
||||
[electronAPI, localRootPaths, searchSpaceId]
|
||||
);
|
||||
|
||||
const handleClearFilesystemRoots = useCallback(async () => {
|
||||
if (!electronAPI?.setAgentFilesystemSettings) return;
|
||||
const updated = await electronAPI.setAgentFilesystemSettings({
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: [],
|
||||
});
|
||||
const updated = await electronAPI.setAgentFilesystemSettings(
|
||||
{
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: [],
|
||||
},
|
||||
searchSpaceId
|
||||
);
|
||||
setFilesystemSettings(updated);
|
||||
}, [electronAPI]);
|
||||
}, [electronAPI, searchSpaceId]);
|
||||
|
||||
const handleFilesystemTabChange = useCallback(
|
||||
async (tab: "cloud" | "local") => {
|
||||
if (!electronAPI?.setAgentFilesystemSettings) return;
|
||||
const updated = await electronAPI.setAgentFilesystemSettings({
|
||||
mode: tab === "cloud" ? "cloud" : "desktop_local_folder",
|
||||
});
|
||||
const updated = await electronAPI.setAgentFilesystemSettings(
|
||||
{
|
||||
mode: tab === "cloud" ? "cloud" : "desktop_local_folder",
|
||||
},
|
||||
searchSpaceId
|
||||
);
|
||||
setFilesystemSettings(updated);
|
||||
},
|
||||
[electronAPI]
|
||||
[electronAPI, searchSpaceId]
|
||||
);
|
||||
|
||||
// AI File Sort state
|
||||
|
|
@ -407,8 +447,8 @@ function AuthenticatedDocumentsSidebar({
|
|||
);
|
||||
|
||||
// Zero queries for tree data
|
||||
const [zeroFolders] = useQuery(queries.folders.bySpace({ searchSpaceId }));
|
||||
const [zeroAllDocs] = useQuery(queries.documents.bySpace({ searchSpaceId }));
|
||||
const [zeroFolders, zeroFoldersResult] = useQuery(queries.folders.bySpace({ searchSpaceId }));
|
||||
const [zeroAllDocs, zeroAllDocsResult] = useQuery(queries.documents.bySpace({ searchSpaceId }));
|
||||
const [agentCreatedDocs, setAgentCreatedDocs] = useAtom(agentCreatedDocumentsAtom);
|
||||
|
||||
const treeFolders: FolderDisplay[] = useMemo(
|
||||
|
|
@ -994,6 +1034,9 @@ function AuthenticatedDocumentsSidebar({
|
|||
const showFilesystemTabs = !isMobile && !!electronAPI && !!filesystemSettings;
|
||||
const currentFilesystemTab =
|
||||
filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud";
|
||||
const showCloudSkeleton =
|
||||
currentFilesystemTab === "cloud" &&
|
||||
(zeroFoldersResult.type !== "complete" || zeroAllDocsResult.type !== "complete");
|
||||
|
||||
const cloudContent = (
|
||||
<>
|
||||
|
|
@ -1106,173 +1149,73 @@ function AuthenticatedDocumentsSidebar({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<FolderTreeView
|
||||
folders={treeFolders}
|
||||
documents={searchFilteredDocuments}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={toggleFolderExpand}
|
||||
mentionedDocIds={mentionedDocIds}
|
||||
onToggleChatMention={handleToggleChatMention}
|
||||
onToggleFolderSelect={handleToggleFolderSelect}
|
||||
onRenameFolder={handleRenameFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
onMoveFolder={handleMoveFolder}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
searchQuery={debouncedSearch.trim() || undefined}
|
||||
onPreviewDocument={(doc) => {
|
||||
openEditorPanel({
|
||||
documentId: doc.id,
|
||||
searchSpaceId,
|
||||
title: doc.title,
|
||||
});
|
||||
}}
|
||||
onEditDocument={(doc) => {
|
||||
openEditorPanel({
|
||||
documentId: doc.id,
|
||||
searchSpaceId,
|
||||
title: doc.title,
|
||||
});
|
||||
}}
|
||||
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
|
||||
onMoveDocument={handleMoveDocument}
|
||||
onExportDocument={handleExportDocument}
|
||||
onVersionHistory={(doc) => setVersionDocId(doc.id)}
|
||||
activeTypes={activeTypes}
|
||||
onDropIntoFolder={handleDropIntoFolder}
|
||||
onReorderFolder={handleReorderFolder}
|
||||
watchedFolderIds={watchedFolderIds}
|
||||
onRescanFolder={handleRescanFolder}
|
||||
onStopWatchingFolder={handleStopWatching}
|
||||
onExportFolder={handleExportFolder}
|
||||
/>
|
||||
{showCloudSkeleton ? (
|
||||
<CloudDocumentsSkeleton />
|
||||
) : (
|
||||
<FolderTreeView
|
||||
folders={treeFolders}
|
||||
documents={searchFilteredDocuments}
|
||||
expandedIds={expandedIds}
|
||||
onToggleExpand={toggleFolderExpand}
|
||||
mentionedDocIds={mentionedDocIds}
|
||||
onToggleChatMention={handleToggleChatMention}
|
||||
onToggleFolderSelect={handleToggleFolderSelect}
|
||||
onRenameFolder={handleRenameFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
onMoveFolder={handleMoveFolder}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
searchQuery={debouncedSearch.trim() || undefined}
|
||||
onPreviewDocument={(doc) => {
|
||||
openEditorPanel({
|
||||
documentId: doc.id,
|
||||
searchSpaceId,
|
||||
title: doc.title,
|
||||
});
|
||||
}}
|
||||
onEditDocument={(doc) => {
|
||||
openEditorPanel({
|
||||
documentId: doc.id,
|
||||
searchSpaceId,
|
||||
title: doc.title,
|
||||
});
|
||||
}}
|
||||
onDeleteDocument={(doc) => handleDeleteDocument(doc.id)}
|
||||
onMoveDocument={handleMoveDocument}
|
||||
onExportDocument={handleExportDocument}
|
||||
onVersionHistory={(doc) => setVersionDocId(doc.id)}
|
||||
activeTypes={activeTypes}
|
||||
onDropIntoFolder={handleDropIntoFolder}
|
||||
onReorderFolder={handleReorderFolder}
|
||||
watchedFolderIds={watchedFolderIds}
|
||||
onRescanFolder={handleRescanFolder}
|
||||
onStopWatchingFolder={handleStopWatching}
|
||||
onExportFolder={handleExportFolder}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const localContent = (
|
||||
<div className="flex min-h-0 flex-1 flex-col select-none">
|
||||
<div className="mx-4 mt-4 mb-3">
|
||||
<div className="flex h-7 w-full items-stretch rounded-lg border bg-muted/50 text-[11px] text-muted-foreground">
|
||||
{localRootPaths.length > 0 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 flex items-center gap-1 rounded-l-lg px-2 text-left transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
title={localRootPaths.join("\n")}
|
||||
aria-label="Manage selected folders"
|
||||
>
|
||||
<Folder className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{localRootPaths.length === 1
|
||||
? "1 folder selected"
|
||||
: `${localRootPaths.length} folders selected`}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56 select-none p-0.5">
|
||||
<DropdownMenuLabel className="px-1.5 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">
|
||||
Selected folders
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator className="mx-1 my-0.5" />
|
||||
{localRootPaths.map((rootPath) => (
|
||||
<DropdownMenuItem
|
||||
key={rootPath}
|
||||
onClick={() => {
|
||||
void handleRemoveFilesystemRoot(rootPath);
|
||||
}}
|
||||
className="group h-8 gap-1.5 px-1.5 text-sm text-foreground"
|
||||
>
|
||||
<Folder className="size-3.5 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{getFolderDisplayName(rootPath)}
|
||||
</span>
|
||||
<X className="size-3 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator className="mx-1 my-0.5" />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="h-8 px-1.5 text-xs text-destructive focus:text-destructive"
|
||||
onClick={() => {
|
||||
void handleClearFilesystemRoots();
|
||||
}}
|
||||
>
|
||||
Clear all folders
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<div
|
||||
className="min-w-0 flex-1 flex items-center gap-1 px-2"
|
||||
title="No local folders selected"
|
||||
>
|
||||
<Folder className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">No local folders selected</span>
|
||||
</div>
|
||||
)}
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="data-[orientation=vertical]:h-3 self-center bg-border"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-8 items-center justify-center rounded-r-lg text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:opacity-50"
|
||||
onClick={() => {
|
||||
void handlePickFilesystemRoot();
|
||||
}}
|
||||
disabled={!canAddMoreLocalRoots}
|
||||
aria-label="Add folder"
|
||||
title="Add folder"
|
||||
>
|
||||
<FolderPlus className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-4 mb-2">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
|
||||
<Search size={13} aria-hidden="true" />
|
||||
</div>
|
||||
<Input
|
||||
ref={localSearchInputRef}
|
||||
className="peer h-8 w-full pl-8 pr-8 text-sm bg-sidebar border-border/60 select-none focus:select-text"
|
||||
value={localSearch}
|
||||
onChange={(e) => setLocalSearch(e.target.value)}
|
||||
placeholder="Search local files"
|
||||
type="text"
|
||||
aria-label="Search local files"
|
||||
/>
|
||||
{Boolean(localSearch) && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex h-full w-8 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Clear local search"
|
||||
onClick={() => {
|
||||
setLocalSearch("");
|
||||
localSearchInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<X size={13} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<LocalFilesystemBrowser
|
||||
rootPaths={localRootPaths}
|
||||
searchSpaceId={searchSpaceId}
|
||||
searchQuery={debouncedLocalSearch.trim() || undefined}
|
||||
onOpenFile={(localFilePath) => {
|
||||
openEditorPanel({
|
||||
kind: "local_file",
|
||||
localFilePath,
|
||||
title: localFilePath.split("/").pop() || localFilePath,
|
||||
searchSpaceId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DesktopLocalTabContent
|
||||
localRootPaths={localRootPaths}
|
||||
canAddMoreLocalRoots={canAddMoreLocalRoots}
|
||||
maxLocalFilesystemRoots={MAX_LOCAL_FILESYSTEM_ROOTS}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onPickFilesystemRoot={handlePickFilesystemRoot}
|
||||
onRemoveFilesystemRoot={handleRemoveFilesystemRoot}
|
||||
onClearFilesystemRoots={handleClearFilesystemRoots}
|
||||
onOpenLocalFile={(localFilePath) => {
|
||||
openEditorPanel({
|
||||
kind: "local_file",
|
||||
localFilePath,
|
||||
title: localFilePath.split("/").pop() || localFilePath,
|
||||
searchSpaceId,
|
||||
});
|
||||
}}
|
||||
electronAvailable={!!electronAPI}
|
||||
/>
|
||||
);
|
||||
|
||||
const documentsContent = (
|
||||
|
|
@ -1305,16 +1248,16 @@ function AuthenticatedDocumentsSidebar({
|
|||
className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
title="Cloud"
|
||||
>
|
||||
<Server className="size-3" />
|
||||
<span>Cloud</span>
|
||||
<Server className="size-3 shrink-0" />
|
||||
<span className="leading-none">Cloud</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="local"
|
||||
className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
title="Local"
|
||||
>
|
||||
<Laptop className="size-3" />
|
||||
<span>Local</span>
|
||||
<Laptop className="size-3 shrink-0" />
|
||||
<span className="leading-none">Local</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
|
@ -1366,7 +1309,7 @@ function AuthenticatedDocumentsSidebar({
|
|||
{cloudContent}
|
||||
</TabsContent>
|
||||
<TabsContent value="local" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
{localContent}
|
||||
{currentFilesystemTab === "local" ? localContent : null}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
|
|
@ -1904,10 +1847,13 @@ function AnonymousDocumentsSidebar({
|
|||
type="button"
|
||||
onClick={handleAnonUploadClick}
|
||||
disabled={isUploading}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-primary/30 px-4 py-6 text-sm text-primary transition-colors hover:border-primary/60 hover:bg-primary/5 cursor-pointer disabled:opacity-50 disabled:pointer-events-none"
|
||||
className="relative flex w-full items-center justify-center rounded-lg border-2 border-dashed border-primary/30 px-4 py-6 text-sm text-primary transition-colors hover:border-primary/60 hover:bg-primary/5 cursor-pointer disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{isUploading ? "Uploading..." : "Upload a document"}
|
||||
<span className={`flex items-center gap-2 ${isUploading ? "opacity-0" : ""}`}>
|
||||
<Upload className="size-4" />
|
||||
Upload a document
|
||||
</span>
|
||||
{isUploading && <Spinner size="sm" className="absolute" />}
|
||||
</button>
|
||||
<p className="mt-2 text-[11px] text-muted-foreground leading-relaxed">
|
||||
Text, code, CSV, and HTML files only. Create an account for PDFs, images, and 30+
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
Inbox,
|
||||
LayoutGrid,
|
||||
ListFilter,
|
||||
MessageSquare,
|
||||
MessageCircleReply,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
|
@ -847,7 +847,7 @@ export function InboxSidebarContent({
|
|||
<TabsList stretch showBottomBorder size="sm">
|
||||
<TabsTrigger value="comments">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<MessageCircleReply className="h-4 w-4" />
|
||||
<span>{t("comments") || "Comments"}</span>
|
||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||
{formatInboxCount(comments.unreadCount)}
|
||||
|
|
@ -1032,7 +1032,7 @@ export function InboxSidebarContent({
|
|||
) : (
|
||||
<div className="text-center py-8">
|
||||
{activeTab === "comments" ? (
|
||||
<MessageSquare className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||
<MessageCircleReply className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||
) : (
|
||||
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, FileText, Folder } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, FileText, Folder, FolderOpen } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { DEFAULT_EXCLUDE_PATTERNS } from "@/components/sources/FolderWatchDialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
|
||||
|
||||
interface LocalFilesystemBrowserProps {
|
||||
rootPaths: string[];
|
||||
searchSpaceId: number;
|
||||
active?: boolean;
|
||||
searchQuery?: string;
|
||||
onOpenFile: (fullPath: string) => void;
|
||||
expandedFolderKeys?: Set<string>;
|
||||
onExpandedFolderKeysChange?: (nextExpandedKeys: Set<string>) => void;
|
||||
}
|
||||
|
||||
interface LocalFolderFileEntry {
|
||||
|
|
@ -39,6 +42,53 @@ type LocalRootMount = {
|
|||
rootPath: string;
|
||||
};
|
||||
|
||||
type MountLoadStatus = "idle" | "loading" | "complete" | "error";
|
||||
|
||||
const LOCAL_OPENABLE_EXTENSIONS = [
|
||||
".md",
|
||||
".markdown",
|
||||
".txt",
|
||||
".json",
|
||||
".yaml",
|
||||
".yml",
|
||||
".csv",
|
||||
".tsv",
|
||||
".xml",
|
||||
".html",
|
||||
".htm",
|
||||
".css",
|
||||
".scss",
|
||||
".sass",
|
||||
".sql",
|
||||
".toml",
|
||||
".ini",
|
||||
".conf",
|
||||
".log",
|
||||
".py",
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".ts",
|
||||
".tsx",
|
||||
".java",
|
||||
".kt",
|
||||
".kts",
|
||||
".go",
|
||||
".rs",
|
||||
".rb",
|
||||
".php",
|
||||
".swift",
|
||||
".r",
|
||||
".lua",
|
||||
".sh",
|
||||
".bash",
|
||||
".zsh",
|
||||
".fish",
|
||||
".env",
|
||||
".mk",
|
||||
];
|
||||
|
||||
const getFolderDisplayName = (rootPath: string): string =>
|
||||
rootPath.split(/[\\/]/).at(-1) || rootPath;
|
||||
|
||||
|
|
@ -69,24 +119,82 @@ function toMountedVirtualPath(mount: string, relativePath: string): string {
|
|||
return `/${mount}${toVirtualPath(relativePath)}`;
|
||||
}
|
||||
|
||||
function getNormalizedExtension(pathValue: string): string {
|
||||
const fileName = getFileName(pathValue).toLowerCase();
|
||||
if (!fileName) return "";
|
||||
if (fileName === "dockerfile" || fileName === "makefile") {
|
||||
return `.${fileName}`;
|
||||
}
|
||||
const dotIndex = fileName.lastIndexOf(".");
|
||||
if (dotIndex <= 0) return "";
|
||||
return fileName.slice(dotIndex);
|
||||
}
|
||||
|
||||
export function LocalFilesystemBrowser({
|
||||
rootPaths,
|
||||
searchSpaceId,
|
||||
active = true,
|
||||
searchQuery,
|
||||
onOpenFile,
|
||||
expandedFolderKeys,
|
||||
onExpandedFolderKeysChange,
|
||||
}: LocalFilesystemBrowserProps) {
|
||||
const electronAPI = useElectronAPI();
|
||||
const [rootStateMap, setRootStateMap] = useState<Record<string, RootLoadState>>({});
|
||||
const [expandedFolderKeys, setExpandedFolderKeys] = useState<Set<string>>(new Set());
|
||||
const [internalExpandedFolderKeys, setInternalExpandedFolderKeys] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [mountByRootKey, setMountByRootKey] = useState<Map<string, string>>(new Map());
|
||||
const supportedExtensions = useMemo(() => Array.from(getSupportedExtensionsSet()), []);
|
||||
const [mountStatus, setMountStatus] = useState<MountLoadStatus>("idle");
|
||||
const [mountRefreshInFlight, setMountRefreshInFlight] = useState(false);
|
||||
const [reloadNonceByRoot, setReloadNonceByRoot] = useState<Record<string, number>>({});
|
||||
const lastLoadedSignatureByRootRef = useRef<Map<string, string>>(new Map());
|
||||
const hasLoadedMountsOnceRef = useRef(false);
|
||||
const hasResolvedAtLeastOneRootRef = useRef(false);
|
||||
const openableExtensions = useMemo(() => new Set(LOCAL_OPENABLE_EXTENSIONS), []);
|
||||
const isWindowsPlatform = electronAPI?.versions.platform === "win32";
|
||||
const effectiveExpandedFolderKeys = expandedFolderKeys ?? internalExpandedFolderKeys;
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronAPI?.listFolderFiles) return;
|
||||
if (!active) return;
|
||||
if (!electronAPI?.listAgentFilesystemFiles) {
|
||||
for (const rootPath of rootPaths) {
|
||||
setRootStateMap((prev) => ({
|
||||
...prev,
|
||||
[rootPath]: {
|
||||
loading: false,
|
||||
error: "Desktop app update required for local mode browsing.",
|
||||
files: [],
|
||||
},
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const rootEntries = rootPaths.map((rootPath) => ({
|
||||
rootPath,
|
||||
rootKey: normalizeRootPathForLookup(rootPath, isWindowsPlatform),
|
||||
}));
|
||||
const activeRootKeys = new Set(rootEntries.map((entry) => entry.rootKey));
|
||||
for (const key of Array.from(lastLoadedSignatureByRootRef.current.keys())) {
|
||||
if (!activeRootKeys.has(key)) {
|
||||
lastLoadedSignatureByRootRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
const rootsToReload = rootEntries.filter(({ rootKey }) => {
|
||||
const nonce = reloadNonceByRoot[rootKey] ?? 0;
|
||||
const signature = `${searchSpaceId}:${rootKey}:${nonce}`;
|
||||
return lastLoadedSignatureByRootRef.current.get(rootKey) !== signature;
|
||||
});
|
||||
if (rootsToReload.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const { rootKey } of rootsToReload) {
|
||||
const nonce = reloadNonceByRoot[rootKey] ?? 0;
|
||||
lastLoadedSignatureByRootRef.current.set(rootKey, `${searchSpaceId}:${rootKey}:${nonce}`);
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
for (const rootPath of rootPaths) {
|
||||
for (const { rootPath } of rootsToReload) {
|
||||
setRootStateMap((prev) => ({
|
||||
...prev,
|
||||
[rootPath]: {
|
||||
|
|
@ -98,16 +206,12 @@ export function LocalFilesystemBrowser({
|
|||
}
|
||||
|
||||
void Promise.all(
|
||||
rootPaths.map(async (rootPath) => {
|
||||
rootsToReload.map(async ({ rootPath }) => {
|
||||
try {
|
||||
const files = (await electronAPI.listFolderFiles({
|
||||
path: rootPath,
|
||||
name: getFolderDisplayName(rootPath),
|
||||
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
|
||||
fileExtensions: supportedExtensions,
|
||||
rootFolderId: null,
|
||||
const files = (await electronAPI.listAgentFilesystemFiles({
|
||||
rootPath,
|
||||
searchSpaceId,
|
||||
active: true,
|
||||
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
|
||||
})) as LocalFolderFileEntry[];
|
||||
if (cancelled) return;
|
||||
setRootStateMap((prev) => ({
|
||||
|
|
@ -135,32 +239,114 @@ export function LocalFilesystemBrowser({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [electronAPI, rootPaths, searchSpaceId, supportedExtensions]);
|
||||
}, [active, electronAPI, isWindowsPlatform, reloadNonceByRoot, rootPaths, searchSpaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) return;
|
||||
lastLoadedSignatureByRootRef.current.clear();
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronAPI?.startAgentFilesystemTreeWatch) return;
|
||||
if (!electronAPI?.stopAgentFilesystemTreeWatch) return;
|
||||
if (!electronAPI?.onAgentFilesystemTreeDirty) return;
|
||||
if (!active) return;
|
||||
if (rootPaths.length === 0) {
|
||||
void electronAPI.stopAgentFilesystemTreeWatch(searchSpaceId);
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = electronAPI.onAgentFilesystemTreeDirty(
|
||||
(event: {
|
||||
searchSpaceId: number | null;
|
||||
reason: "watcher_event" | "safety_poll";
|
||||
rootPath: string;
|
||||
changedPath: string | null;
|
||||
timestamp: number;
|
||||
}) => {
|
||||
if ((event.searchSpaceId ?? null) !== (searchSpaceId ?? null)) {
|
||||
return;
|
||||
}
|
||||
const eventRootKey = normalizeRootPathForLookup(event.rootPath, isWindowsPlatform);
|
||||
const knownRootKeys = new Set(
|
||||
rootPaths.map((rootPath) => normalizeRootPathForLookup(rootPath, isWindowsPlatform))
|
||||
);
|
||||
if (!knownRootKeys.has(eventRootKey)) {
|
||||
setReloadNonceByRoot((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const rootKey of knownRootKeys) {
|
||||
next[rootKey] = (prev[rootKey] ?? 0) + 1;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setReloadNonceByRoot((prev) => ({
|
||||
...prev,
|
||||
[eventRootKey]: (prev[eventRootKey] ?? 0) + 1,
|
||||
}));
|
||||
}
|
||||
);
|
||||
void electronAPI.startAgentFilesystemTreeWatch({
|
||||
searchSpaceId,
|
||||
rootPaths,
|
||||
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
void electronAPI.stopAgentFilesystemTreeWatch(searchSpaceId);
|
||||
};
|
||||
}, [active, electronAPI, isWindowsPlatform, rootPaths, searchSpaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronAPI?.getAgentFilesystemMounts) {
|
||||
setMountStatus("error");
|
||||
setMountByRootKey(new Map());
|
||||
return;
|
||||
}
|
||||
if (rootPaths.length === 0) {
|
||||
setMountByRootKey(new Map());
|
||||
setMountStatus("complete");
|
||||
setMountRefreshInFlight(false);
|
||||
hasLoadedMountsOnceRef.current = true;
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const isInitialMountLoad = !hasLoadedMountsOnceRef.current;
|
||||
if (isInitialMountLoad) {
|
||||
setMountStatus("loading");
|
||||
} else {
|
||||
setMountRefreshInFlight(true);
|
||||
}
|
||||
void electronAPI
|
||||
.getAgentFilesystemMounts()
|
||||
.getAgentFilesystemMounts(searchSpaceId)
|
||||
.then((mounts: LocalRootMount[]) => {
|
||||
if (cancelled) return;
|
||||
const next = new Map<string, string>();
|
||||
for (const entry of mounts) {
|
||||
next.set(normalizeRootPathForLookup(entry.rootPath, isWindowsPlatform), entry.mount);
|
||||
const normalizedRootKey = normalizeRootPathForLookup(entry.rootPath, isWindowsPlatform);
|
||||
next.set(normalizedRootKey, entry.mount);
|
||||
}
|
||||
setMountByRootKey(next);
|
||||
setMountStatus("complete");
|
||||
hasLoadedMountsOnceRef.current = true;
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setMountByRootKey(new Map());
|
||||
if (isInitialMountLoad) {
|
||||
setMountByRootKey(new Map());
|
||||
setMountStatus("error");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setMountRefreshInFlight(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [electronAPI, isWindowsPlatform, rootPaths]);
|
||||
}, [electronAPI, isWindowsPlatform, rootPaths, searchSpaceId]);
|
||||
|
||||
const treeByRoot = useMemo(() => {
|
||||
const query = searchQuery?.trim().toLowerCase() ?? "";
|
||||
|
|
@ -193,21 +379,30 @@ export function LocalFilesystemBrowser({
|
|||
});
|
||||
}, [rootPaths, rootStateMap, searchQuery]);
|
||||
|
||||
const toggleFolder = useCallback((folderKey: string) => {
|
||||
setExpandedFolderKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(folderKey)) {
|
||||
next.delete(folderKey);
|
||||
} else {
|
||||
next.add(folderKey);
|
||||
const toggleFolder = useCallback(
|
||||
(folderKey: string) => {
|
||||
const update = (prev: Set<string>) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(folderKey)) {
|
||||
next.delete(folderKey);
|
||||
} else {
|
||||
next.add(folderKey);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
if (onExpandedFolderKeysChange) {
|
||||
onExpandedFolderKeysChange(update(effectiveExpandedFolderKeys));
|
||||
return;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
setInternalExpandedFolderKeys(update);
|
||||
},
|
||||
[effectiveExpandedFolderKeys, onExpandedFolderKeysChange]
|
||||
);
|
||||
|
||||
const renderFolder = useCallback(
|
||||
(folder: LocalFolderNode, depth: number, mount: string) => {
|
||||
const isExpanded = expandedFolderKeys.has(folder.key);
|
||||
const isExpanded = effectiveExpandedFolderKeys.has(folder.key);
|
||||
const FolderIcon = isExpanded ? FolderOpen : Folder;
|
||||
const childFolders = Array.from(folder.folders.values()).sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
|
|
@ -226,32 +421,47 @@ export function LocalFilesystemBrowser({
|
|||
) : (
|
||||
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<FolderIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{folder.name}</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<>
|
||||
{childFolders.map((childFolder) => renderFolder(childFolder, depth + 1, mount))}
|
||||
{files.map((file) => (
|
||||
<button
|
||||
key={file.fullPath}
|
||||
type="button"
|
||||
onClick={() => onOpenFile(toMountedVirtualPath(mount, file.relativePath))}
|
||||
className="flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors hover:bg-muted/60"
|
||||
style={{ paddingInlineStart: `${(depth + 1) * 12 + 22}px` }}
|
||||
title={file.fullPath}
|
||||
draggable={false}
|
||||
>
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{getFileName(file.relativePath)}</span>
|
||||
</button>
|
||||
))}
|
||||
{files.map((file) => {
|
||||
const extension = getNormalizedExtension(file.relativePath);
|
||||
const isOpenable = openableExtensions.has(extension);
|
||||
return (
|
||||
<button
|
||||
key={file.fullPath}
|
||||
type="button"
|
||||
onClick={
|
||||
isOpenable
|
||||
? () => onOpenFile(toMountedVirtualPath(mount, file.relativePath))
|
||||
: undefined
|
||||
}
|
||||
className={`flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors ${
|
||||
isOpenable ? "hover:bg-muted/60" : "cursor-not-allowed opacity-60"
|
||||
}`}
|
||||
style={{ paddingInlineStart: `${(depth + 1) * 12 + 22}px` }}
|
||||
title={
|
||||
isOpenable
|
||||
? file.fullPath
|
||||
: `${file.fullPath}\nThis file type cannot be opened in the editor.`
|
||||
}
|
||||
draggable={false}
|
||||
disabled={!isOpenable}
|
||||
>
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{getFileName(file.relativePath)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[expandedFolderKeys, onOpenFile, toggleFolder]
|
||||
[effectiveExpandedFolderKeys, onOpenFile, openableExtensions, toggleFolder]
|
||||
);
|
||||
|
||||
if (rootPaths.length === 0) {
|
||||
|
|
@ -265,6 +475,43 @@ export function LocalFilesystemBrowser({
|
|||
);
|
||||
}
|
||||
|
||||
const allRootsLoaded = rootPaths.every((rootPath) => {
|
||||
const state = rootStateMap[rootPath];
|
||||
return !!state && !state.loading;
|
||||
});
|
||||
const mountsSettled = mountStatus === "complete" || mountStatus === "error";
|
||||
if (allRootsLoaded && mountsSettled && rootPaths.length > 0) {
|
||||
hasResolvedAtLeastOneRootRef.current = true;
|
||||
}
|
||||
const showInitialLoading =
|
||||
!hasResolvedAtLeastOneRootRef.current && (!allRootsLoaded || !mountsSettled);
|
||||
|
||||
if (showInitialLoading) {
|
||||
const rows = [
|
||||
{ id: "local-row-1", widthClass: "w-44" },
|
||||
{ id: "local-row-2", widthClass: "w-32" },
|
||||
{ id: "local-row-3", widthClass: "w-32" },
|
||||
{ id: "local-row-4", widthClass: "w-44" },
|
||||
{ id: "local-row-5", widthClass: "w-32" },
|
||||
{ id: "local-row-6", widthClass: "w-32" },
|
||||
{ id: "local-row-7", widthClass: "w-44" },
|
||||
{ id: "local-row-8", widthClass: "w-32" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2">
|
||||
<div className="space-y-1">
|
||||
{rows.map((row) => (
|
||||
<div key={row.id} className="flex h-8 items-center gap-2 px-2">
|
||||
<Skeleton className="h-4 w-4 rounded-sm" />
|
||||
<Skeleton className={`h-4 ${row.widthClass}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2">
|
||||
{treeByRoot.map(({ rootPath, rootNode, matchCount, totalCount }) => {
|
||||
|
|
@ -273,12 +520,11 @@ export function LocalFilesystemBrowser({
|
|||
const mount = mountByRootKey.get(rootKey);
|
||||
if (!state || state.loading) {
|
||||
return (
|
||||
<div
|
||||
key={rootPath}
|
||||
className="flex h-16 items-center gap-2 px-3 text-sm text-muted-foreground"
|
||||
>
|
||||
<Spinner size="sm" />
|
||||
<span>Loading {getFolderDisplayName(rootPath)}...</span>
|
||||
<div key={rootPath} className="mb-1 px-3 py-2 text-xs text-muted-foreground/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner className="size-3.5" />
|
||||
<span>Loading {getFolderDisplayName(rootPath)}...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -297,11 +543,24 @@ export function LocalFilesystemBrowser({
|
|||
return (
|
||||
<div key={rootPath} className="mb-1">
|
||||
{mount ? renderFolder(rootNode, 0, mount) : null}
|
||||
{!mount && (
|
||||
{!mount && (mountRefreshInFlight || mountStatus === "loading") && (
|
||||
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner className="size-3.5" />
|
||||
<span>Loading {getFolderDisplayName(rootPath)}...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!mount && mountStatus === "complete" && !mountRefreshInFlight && (
|
||||
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
|
||||
Unable to resolve mounted root for this folder.
|
||||
</div>
|
||||
)}
|
||||
{!mount && mountStatus === "error" && (
|
||||
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
|
||||
Failed to resolve local folder mounts.
|
||||
</div>
|
||||
)}
|
||||
{isEmpty && (
|
||||
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
|
||||
No supported files found in this folder.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Download, FileQuestionMark, FileText, Loader2, Pencil, RefreshCw } from "lucide-react";
|
||||
import { Download, FileQuestionMark, FileText, Pencil, RefreshCw } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -8,6 +8,7 @@ import { PlateEditor } from "@/components/editor/plate-editor";
|
|||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
|
||||
|
|
@ -278,7 +279,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 gap-1.5"
|
||||
className="relative shrink-0"
|
||||
disabled={downloading}
|
||||
onClick={async () => {
|
||||
setDownloading(true);
|
||||
|
|
@ -307,12 +308,13 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
}
|
||||
}}
|
||||
>
|
||||
{downloading ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<span
|
||||
className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
)}
|
||||
{downloading ? "Preparing..." : "Download .md"}
|
||||
Download .md
|
||||
</span>
|
||||
{downloading && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Edit3,
|
||||
Pencil,
|
||||
ImageIcon,
|
||||
Layers,
|
||||
Plus,
|
||||
|
|
@ -320,6 +320,30 @@ export function ModelSelector({
|
|||
[isMobile]
|
||||
);
|
||||
|
||||
const scrollProviderSidebar = useCallback(
|
||||
(direction: "backward" | "forward") => {
|
||||
const el = providerSidebarRef.current;
|
||||
if (!el) return;
|
||||
const delta = isMobile
|
||||
? Math.max(56, Math.floor(el.clientWidth * 0.5))
|
||||
: Math.max(44, Math.floor(el.clientHeight * 0.4));
|
||||
|
||||
if (isMobile) {
|
||||
el.scrollBy({
|
||||
left: direction === "backward" ? -delta : delta,
|
||||
behavior: "smooth",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
el.scrollBy({
|
||||
top: direction === "backward" ? -delta : delta,
|
||||
behavior: "smooth",
|
||||
});
|
||||
},
|
||||
[isMobile]
|
||||
);
|
||||
|
||||
// Cmd/Ctrl+M shortcut (desktop only)
|
||||
useEffect(() => {
|
||||
if (isMobile) return;
|
||||
|
|
@ -716,17 +740,40 @@ export function ModelSelector({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 border-border/50 flex",
|
||||
isMobile ? "flex-row items-center border-b border-border/40" : "flex-col w-10 border-r"
|
||||
"shrink-0 border-border/50 flex relative",
|
||||
isMobile
|
||||
? "flex-row items-center border-b border-border/40"
|
||||
: "flex-col w-10 border-r"
|
||||
)}
|
||||
>
|
||||
{!isMobile && sidebarScrollPos !== "top" && (
|
||||
<div className="flex items-center justify-center py-0.5 pointer-events-none">
|
||||
<ChevronUp className="size-3 text-muted-foreground" />
|
||||
{!isMobile && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0 right-0 z-10 h-5 flex items-center justify-center transition-all duration-200 ease-out",
|
||||
sidebarScrollPos === "top"
|
||||
? "opacity-0 -translate-y-1 pointer-events-none"
|
||||
: "opacity-100 translate-y-0 pointer-events-auto"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll providers up"
|
||||
onClick={() => scrollProviderSidebar("backward")}
|
||||
className="flex h-4 w-4 items-center justify-center rounded-sm text-muted-foreground/90 hover:text-foreground hover:bg-accent/60 transition-colors"
|
||||
>
|
||||
<ChevronUp className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isMobile && sidebarScrollPos !== "top" && (
|
||||
<div className="flex items-center justify-center px-0.5 shrink-0 pointer-events-none">
|
||||
{isMobile && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 top-0 bottom-0 z-10 w-5 flex items-center justify-center transition-all duration-200 ease-out pointer-events-none",
|
||||
sidebarScrollPos === "top"
|
||||
? "opacity-0 -translate-x-1"
|
||||
: "opacity-100 translate-x-0"
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="size-3 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -802,13 +849,34 @@ export function ModelSelector({
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
{!isMobile && sidebarScrollPos !== "bottom" && (
|
||||
<div className="flex items-center justify-center py-0.5 pointer-events-none">
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
{!isMobile && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-0 left-0 right-0 z-10 h-5 flex items-center justify-center transition-all duration-200 ease-out",
|
||||
sidebarScrollPos === "bottom"
|
||||
? "opacity-0 translate-y-1 pointer-events-none"
|
||||
: "opacity-100 translate-y-0 pointer-events-auto"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll providers down"
|
||||
onClick={() => scrollProviderSidebar("forward")}
|
||||
className="flex h-4 w-4 items-center justify-center rounded-sm text-muted-foreground/90 hover:text-foreground hover:bg-accent/60 transition-colors"
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isMobile && sidebarScrollPos !== "bottom" && (
|
||||
<div className="flex items-center justify-center px-0.5 shrink-0 pointer-events-none">
|
||||
{isMobile && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 bottom-0 z-10 w-5 flex items-center justify-center transition-all duration-200 ease-out pointer-events-none",
|
||||
sidebarScrollPos === "bottom"
|
||||
? "opacity-0 translate-x-1"
|
||||
: "opacity-100 translate-x-0"
|
||||
)}
|
||||
>
|
||||
<ChevronRight className="size-3 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -923,7 +991,7 @@ export function ModelSelector({
|
|||
className="size-7 rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => handleEditItem(e, item)}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
{isSelected && <Check className="size-4 text-primary shrink-0" />}
|
||||
|
|
|
|||
|
|
@ -79,8 +79,11 @@ export function PublicChatSnapshotRow({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"absolute right-0 h-6 w-6 shrink-0 hover:bg-transparent",
|
||||
dropdownOpen ? "opacity-100" : "sm:opacity-0 sm:group-hover:opacity-100"
|
||||
"absolute right-0 h-6 w-6 shrink-0",
|
||||
"hover:bg-accent",
|
||||
dropdownOpen
|
||||
? "opacity-100 bg-accent hover:bg-accent"
|
||||
: "sm:opacity-0 sm:group-hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { ZoomInIcon, ZoomOutIcon } from "lucide-react";
|
||||
import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist";
|
||||
import * as pdfjsLib from "pdfjs-dist";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getAuthHeaders } from "@/lib/auth-utils";
|
||||
|
|
@ -16,6 +16,8 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
|||
interface PdfViewerProps {
|
||||
pdfUrl: string;
|
||||
isPublic?: boolean;
|
||||
/** Extra actions rendered on the right side of the zoom toolbar (e.g. download, version switcher) */
|
||||
toolbarActions?: ReactNode;
|
||||
}
|
||||
|
||||
interface PageDimensions {
|
||||
|
|
@ -30,7 +32,7 @@ const PAGE_GAP = 12;
|
|||
const SCROLL_DEBOUNCE_MS = 30;
|
||||
const BUFFER_PAGES = 1;
|
||||
|
||||
export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
|
||||
export function PdfViewer({ pdfUrl, isPublic = false, toolbarActions }: PdfViewerProps) {
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -286,29 +288,33 @@ export function PdfViewer({ pdfUrl, isPublic = false }: PdfViewerProps) {
|
|||
<div className="flex flex-col h-full">
|
||||
{numPages > 0 && (
|
||||
<div
|
||||
className={`flex items-center justify-center gap-2 px-4 py-2 border-b shrink-0 select-none ${isPublic ? "bg-main-panel" : "bg-sidebar"}`}
|
||||
className={`flex items-center px-4 py-2 border-b shrink-0 select-none ${isPublic ? "bg-main-panel" : "bg-sidebar"}`}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={zoomOut}
|
||||
disabled={scale <= MIN_ZOOM}
|
||||
className="size-7"
|
||||
>
|
||||
<ZoomOutIcon className="size-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground tabular-nums min-w-[40px] text-center">
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={zoomIn}
|
||||
disabled={scale >= MAX_ZOOM}
|
||||
className="size-7"
|
||||
>
|
||||
<ZoomInIcon className="size-4" />
|
||||
</Button>
|
||||
<div className="flex-1" aria-hidden="true" />
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={zoomOut}
|
||||
disabled={scale <= MIN_ZOOM}
|
||||
className="size-7"
|
||||
>
|
||||
<ZoomOutIcon className="size-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground tabular-nums min-w-[40px] text-center">
|
||||
{Math.round(scale * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={zoomIn}
|
||||
disabled={scale >= MAX_ZOOM}
|
||||
className="size-7"
|
||||
>
|
||||
<ZoomInIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-end gap-1">{toolbarActions}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Check, ChevronDownIcon, Copy, Pencil, XIcon } from "lucide-react";
|
||||
import { Check, ChevronDownIcon, Copy, Download, Pencil, XIcon } from "lucide-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -309,6 +309,7 @@ export function ReportPanelContent({
|
|||
const isResume = reportContent?.content_type === "typst";
|
||||
const showReportEditingTier = !isResume;
|
||||
const hasUnsavedChanges = editedMarkdown !== null;
|
||||
const showDesktopHeader = !!onClose;
|
||||
|
||||
const handleCancelEditing = useCallback(() => {
|
||||
setEditedMarkdown(null);
|
||||
|
|
@ -316,153 +317,177 @@ export function ReportPanelContent({
|
|||
setIsEditing(false);
|
||||
}, []);
|
||||
|
||||
const exportButton = !isEditing && (
|
||||
<>
|
||||
{isResume ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={() => handleExport("pdf")}
|
||||
disabled={isLoading || !reportContent?.content || exporting !== null}
|
||||
>
|
||||
{exporting === "pdf" ? <Spinner size="xs" /> : <Download className="size-3.5" />}
|
||||
<span className="sr-only">Download report</span>
|
||||
</Button>
|
||||
) : (
|
||||
<DropdownMenu modal={insideDrawer ? false : undefined}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
disabled={isLoading || !reportContent?.content}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
<span className="sr-only">Export report</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={`min-w-[200px] select-none${insideDrawer ? " z-[100]" : ""}`}
|
||||
>
|
||||
<ExportDropdownItems
|
||||
onExport={handleExport}
|
||||
exporting={exporting}
|
||||
showAllFormats={!shareToken}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const versionSwitcher = !isEditing && versions.length > 1 && (
|
||||
<DropdownMenu modal={insideDrawer ? false : undefined}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-6 gap-1 px-1.5 text-xs">
|
||||
v{activeVersionIndex + 1}
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className={`min-w-[120px] select-none${insideDrawer ? " z-[100]" : ""}`}
|
||||
>
|
||||
{versions.map((v, i) => (
|
||||
<DropdownMenuItem
|
||||
key={v.id}
|
||||
onClick={() => setActiveReportId(v.id)}
|
||||
className={v.id === activeReportId ? "bg-accent font-medium" : ""}
|
||||
>
|
||||
Version {i + 1}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
const copyButton = !isEditing && showReportEditingTier && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={() => {
|
||||
void handleCopy();
|
||||
}}
|
||||
disabled={isLoading || !reportContent?.content}
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
<span className="sr-only">{copied ? "Copied report content" : "Copy report content"}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const editingActions = showReportEditingTier &&
|
||||
!isReadOnly &&
|
||||
(isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={handleCancelEditing}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="relative h-6 w-[56px] px-0 text-xs"
|
||||
onClick={async () => {
|
||||
const saveSucceeded = await handleSave();
|
||||
if (saveSucceeded) setIsEditing(false);
|
||||
}}
|
||||
disabled={saving || !hasUnsavedChanges}
|
||||
>
|
||||
<span className={saving ? "opacity-0" : ""}>Save</span>
|
||||
{saving && <Spinner size="xs" className="absolute" />}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={() => {
|
||||
setEditedMarkdown(null);
|
||||
changeCountRef.current = 0;
|
||||
setIsEditing(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
<span className="sr-only">Edit report</span>
|
||||
</Button>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Action bar — always visible; buttons are disabled while loading */}
|
||||
<div className="flex h-14 items-center justify-between px-4 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Export — plain button for resume (typst), dropdown for others */}
|
||||
{reportContent?.content_type === "typst" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport("pdf")}
|
||||
disabled={isLoading || !reportContent?.content || exporting !== null}
|
||||
className={`h-8 min-w-[100px] px-3.5 py-4 text-[15px] ${isPublic ? "bg-main-panel" : "bg-sidebar"} select-none`}
|
||||
>
|
||||
{exporting === "pdf" ? <Spinner size="xs" /> : "Download"}
|
||||
</Button>
|
||||
) : (
|
||||
<DropdownMenu modal={insideDrawer ? false : undefined}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isLoading || !reportContent?.content}
|
||||
className={`h-8 px-3.5 py-4 text-[15px] gap-1.5 ${isPublic ? "bg-main-panel" : "bg-sidebar"} select-none`}
|
||||
>
|
||||
Export
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={`min-w-[200px] select-none${insideDrawer ? " z-[100]" : ""}`}
|
||||
>
|
||||
<ExportDropdownItems
|
||||
onExport={handleExport}
|
||||
exporting={exporting}
|
||||
showAllFormats={!shareToken}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Version switcher — only shown when multiple versions exist */}
|
||||
{versions.length > 1 && (
|
||||
<DropdownMenu modal={insideDrawer ? false : undefined}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`h-8 px-3.5 py-4 text-[15px] gap-1.5 ${isPublic ? "bg-main-panel" : "bg-sidebar"} select-none`}
|
||||
>
|
||||
v{activeVersionIndex + 1}
|
||||
<ChevronDownIcon className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={`min-w-[120px] select-none${insideDrawer ? " z-[100]" : ""}`}
|
||||
>
|
||||
{versions.map((v, i) => (
|
||||
<DropdownMenuItem
|
||||
key={v.id}
|
||||
onClick={() => setActiveReportId(v.id)}
|
||||
className={v.id === activeReportId ? "bg-accent font-medium" : ""}
|
||||
>
|
||||
Version {i + 1}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
{onClose && (
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close report panel</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showReportEditingTier && (
|
||||
<div className="flex h-10 items-center justify-between gap-2 border-t border-b px-4 shrink-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{reportContent?.title || title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{!isEditing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={() => {
|
||||
void handleCopy();
|
||||
}}
|
||||
disabled={isLoading || !reportContent?.content}
|
||||
>
|
||||
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
|
||||
<span className="sr-only">
|
||||
{copied ? "Copied report content" : "Copy report content"}
|
||||
</span>
|
||||
{showDesktopHeader ? (
|
||||
<>
|
||||
{/* Header — matches the editor panel "File" header pattern */}
|
||||
<div className="flex h-14 items-center justify-between px-4 shrink-0">
|
||||
<h2 className="text-lg font-medium text-muted-foreground select-none">
|
||||
{isResume ? "Resume" : "Report"}
|
||||
</h2>
|
||||
{onClose && (
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close report panel</span>
|
||||
</Button>
|
||||
)}
|
||||
{!isReadOnly &&
|
||||
(isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={handleCancelEditing}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="relative h-6 w-[56px] px-0 text-xs"
|
||||
onClick={async () => {
|
||||
const saveSucceeded = await handleSave();
|
||||
if (saveSucceeded) setIsEditing(false);
|
||||
}}
|
||||
disabled={saving || !hasUnsavedChanges}
|
||||
>
|
||||
<span className={saving ? "opacity-0" : ""}>Save</span>
|
||||
{saving && <Spinner size="xs" className="absolute" />}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={() => {
|
||||
setEditedMarkdown(null);
|
||||
changeCountRef.current = 0;
|
||||
setIsEditing(true);
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
<span className="sr-only">Edit report</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isResume && (
|
||||
<div className="flex h-10 items-center justify-between gap-2 border-t border-b px-4 shrink-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{reportContent?.title || title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{versionSwitcher}
|
||||
{exportButton}
|
||||
{copyButton}
|
||||
{editingActions}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
!isResume && (
|
||||
<div className="flex h-14 items-center justify-between border-b px-4 shrink-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold truncate">{reportContent?.title || title}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{versionSwitcher}
|
||||
{exportButton}
|
||||
{copyButton}
|
||||
{editingActions}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Report content — skeleton/error/viewer/editor shown only in this area */}
|
||||
|
|
@ -480,6 +505,12 @@ export function ReportPanelContent({
|
|||
<PdfViewer
|
||||
pdfUrl={`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${shareToken ? `/api/v1/public/${shareToken}/reports/${activeReportId}/preview` : `/api/v1/reports/${activeReportId}/preview`}`}
|
||||
isPublic={isPublic}
|
||||
toolbarActions={
|
||||
<>
|
||||
{versionSwitcher}
|
||||
{exportButton}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : reportContent.content ? (
|
||||
isReadOnly ? (
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@ import { useAtomValue } from "jotai";
|
|||
import {
|
||||
AlertCircle,
|
||||
Dot,
|
||||
Edit3,
|
||||
FileText,
|
||||
Info,
|
||||
MessageSquareQuote,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
|
@ -288,7 +287,7 @@ export function AgentModelManager({ searchSpaceId }: AgentModelManagerProps) {
|
|||
onClick={() => openEditDialog(config)}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
|
|
@ -323,7 +322,6 @@ export function AgentModelManager({ searchSpaceId }: AgentModelManagerProps) {
|
|||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
|
||||
>
|
||||
<MessageSquareQuote className="h-2.5 w-2.5 mr-1" />
|
||||
Citations
|
||||
</Badge>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Dot, Edit3, Info, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { AlertCircle, Dot, Info, Pencil, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { deleteImageGenConfigMutationAtom } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
|
||||
import {
|
||||
|
|
@ -116,8 +116,8 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
{/* Header actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
|
@ -284,7 +284,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
onClick={() => openEditDialog(config)}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
|
|
|
|||
|
|
@ -4,21 +4,25 @@ import { useQuery } from "@tanstack/react-query";
|
|||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Edit2,
|
||||
ChevronRight,
|
||||
ScanEye,
|
||||
Pencil,
|
||||
FileText,
|
||||
Globe,
|
||||
Earth,
|
||||
Image,
|
||||
Logs,
|
||||
type LucideIcon,
|
||||
MessageCircle,
|
||||
MessageCircleReply,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
MoreHorizontal,
|
||||
Plug,
|
||||
Unplug,
|
||||
Settings,
|
||||
Shield,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
Users,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -88,7 +92,7 @@ const CATEGORY_CONFIG: Record<
|
|||
},
|
||||
comments: {
|
||||
label: "Comments",
|
||||
icon: MessageCircle,
|
||||
icon: MessageCircleReply,
|
||||
description: "Add annotations to documents",
|
||||
order: 3,
|
||||
},
|
||||
|
|
@ -98,6 +102,24 @@ const CATEGORY_CONFIG: Record<
|
|||
description: "Configure AI model settings",
|
||||
order: 4,
|
||||
},
|
||||
image_generations: {
|
||||
label: "Image Models",
|
||||
icon: Image,
|
||||
description: "Configure image generation model settings",
|
||||
order: 4.1,
|
||||
},
|
||||
vision_configs: {
|
||||
label: "Vision Models",
|
||||
icon: ScanEye,
|
||||
description: "Configure vision model settings",
|
||||
order: 4.2,
|
||||
},
|
||||
video_presentations: {
|
||||
label: "Video Presentations",
|
||||
icon: Video,
|
||||
description: "Generate and manage video presentations",
|
||||
order: 4.3,
|
||||
},
|
||||
podcasts: {
|
||||
label: "Podcasts",
|
||||
icon: Mic,
|
||||
|
|
@ -105,8 +127,8 @@ const CATEGORY_CONFIG: Record<
|
|||
order: 5,
|
||||
},
|
||||
connectors: {
|
||||
label: "Integrations",
|
||||
icon: Plug,
|
||||
label: "Connectors",
|
||||
icon: Unplug,
|
||||
description: "Connect external data sources",
|
||||
order: 6,
|
||||
},
|
||||
|
|
@ -136,10 +158,16 @@ const CATEGORY_CONFIG: Record<
|
|||
},
|
||||
public_sharing: {
|
||||
label: "Public Chat Sharing",
|
||||
icon: Globe,
|
||||
icon: Earth,
|
||||
description: "Share chats publicly via links",
|
||||
order: 11,
|
||||
},
|
||||
general: {
|
||||
label: "General",
|
||||
icon: SlidersHorizontal,
|
||||
description: "General search space permissions",
|
||||
order: 12,
|
||||
},
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
|
|
@ -434,12 +462,11 @@ function RolesContent({
|
|||
|
||||
return (
|
||||
<div key={role.id} className="rounded-lg border border-border/60 overflow-hidden">
|
||||
<div className="flex items-center gap-4 p-4 transition-colors hover:bg-muted/30">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 min-w-0 text-left cursor-pointer"
|
||||
onClick={() => setExpandedRoleId(isExpanded ? null : role.id)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-4 p-4 transition-colors hover:bg-muted/30 cursor-pointer"
|
||||
onClick={() => setExpandedRoleId(isExpanded ? null : role.id)}
|
||||
>
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{role.name}</span>
|
||||
{role.is_system_role && (
|
||||
|
|
@ -458,14 +485,14 @@ function RolesContent({
|
|||
{role.description}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0">
|
||||
<PermissionsBadge permissions={role.permissions} />
|
||||
</div>
|
||||
|
||||
{!role.is_system_role && (
|
||||
<div className="shrink-0" role="none">
|
||||
<div className="shrink-0" role="none" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
|
|
@ -475,7 +502,7 @@ function RolesContent({
|
|||
<DropdownMenuContent align="end" onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
{canUpdate && (
|
||||
<DropdownMenuItem onClick={() => setEditingRoleId(role.id)}>
|
||||
<Edit2 className="h-4 w-4 mr-2" />
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit Role
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
|
@ -515,18 +542,14 @@ function RolesContent({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 p-1 cursor-pointer"
|
||||
onClick={() => setExpandedRoleId(isExpanded ? null : role.id)}
|
||||
>
|
||||
<ChevronDown
|
||||
<div className="shrink-0 p-1">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
||||
isExpanded && "rotate-180"
|
||||
isExpanded && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
|
|
@ -659,52 +682,30 @@ function PermissionsEditor({
|
|||
|
||||
return (
|
||||
<div key={category} className="rounded-lg border border-border/60 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/40 transition-colors">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center gap-2.5 cursor-pointer"
|
||||
onClick={() => toggleCategoryExpanded(category)}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/40 transition-colors cursor-pointer"
|
||||
onClick={() => toggleCategoryExpanded(category)}
|
||||
>
|
||||
<div className="flex-1 flex items-center gap-2.5">
|
||||
<IconComponent className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<span className="font-medium text-sm">{config.label}</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{stats.selected}/{stats.total}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={stats.allSelected}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onCheckedChange={() => onToggleCategory(category)}
|
||||
aria-label={`Select all ${config.label} permissions`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggleCategoryExpanded(category)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"transition-transform duration-200",
|
||||
isExpanded && "rotate-180"
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 text-muted-foreground"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<title>Toggle</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform duration-200",
|
||||
isExpanded && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -726,7 +727,7 @@ function PermissionsEditor({
|
|||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 min-w-0 text-left cursor-pointer"
|
||||
className="flex-1 min-w-0 text-left cursor-pointer focus:outline-none focus-visible:outline-none"
|
||||
onClick={() => onTogglePermission(perm.value)}
|
||||
>
|
||||
<span className="text-sm font-medium">{actionLabel}</span>
|
||||
|
|
@ -855,7 +856,8 @@ function CreateRoleDialog({
|
|||
type="button"
|
||||
onClick={() => applyPreset(key as keyof typeof ROLE_PRESETS)}
|
||||
className={cn(
|
||||
"p-3 rounded-lg border text-left transition-colors hover:bg-muted/40",
|
||||
"p-3 rounded-lg border transition-colors hover:bg-muted/40",
|
||||
"flex items-center justify-center text-center sm:block sm:text-left",
|
||||
selectedPermissions.length > 0 &&
|
||||
preset.permissions.every((p) => selectedPermissions.includes(p))
|
||||
? "border-foreground/30 bg-muted/40"
|
||||
|
|
@ -863,7 +865,7 @@ function CreateRoleDialog({
|
|||
)}
|
||||
>
|
||||
<span className="font-medium text-sm">{preset.name}</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
<p className="hidden sm:block text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{preset.description}
|
||||
</p>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Dot, Edit3, Info, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { AlertCircle, Dot, Info, Pencil, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { deleteVisionLLMConfigMutationAtom } from "@/atoms/vision-llm-config/vision-llm-config-mutation.atoms";
|
||||
|
|
@ -121,7 +121,7 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
|
|||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
|
@ -282,7 +282,7 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
|
|||
onClick={() => openEditDialog(config)}
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
|
|
|
|||
|
|
@ -764,22 +764,16 @@ export function DocumentUploadTab({
|
|||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
className="w-full relative"
|
||||
onClick={handleUpload}
|
||||
disabled={isAnyUploading || fileCount === 0}
|
||||
>
|
||||
{isAnyUploading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
{t("uploading")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
{folderUpload
|
||||
? t("upload_folder_button", { count: fileCount })
|
||||
: t("upload_button", { count: fileCount })}
|
||||
</span>
|
||||
)}
|
||||
<span className={isAnyUploading ? "opacity-0" : ""}>
|
||||
{folderUpload
|
||||
? t("upload_folder_button", { count: fileCount })
|
||||
: t("upload_button", { count: fileCount })}
|
||||
</span>
|
||||
{isAnyUploading && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -137,10 +137,9 @@ function ReportCard({
|
|||
const autoOpenedRef = useRef(false);
|
||||
const [metadata, setMetadata] = useState<{
|
||||
title: string;
|
||||
wordCount: number | null;
|
||||
versionLabel: string | null;
|
||||
content: string | null;
|
||||
}>({ title, wordCount: wordCount ?? null, versionLabel: null, content: null });
|
||||
}>({ title, versionLabel: null, content: null });
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -169,10 +168,8 @@ function ReportCard({
|
|||
}
|
||||
}
|
||||
const resolvedTitle = parsed.data.title || title;
|
||||
const resolvedWordCount = parsed.data.report_metadata?.word_count ?? wordCount ?? null;
|
||||
setMetadata({
|
||||
title: resolvedTitle,
|
||||
wordCount: resolvedWordCount,
|
||||
versionLabel,
|
||||
content: parsed.data.content ?? null,
|
||||
});
|
||||
|
|
@ -182,7 +179,7 @@ function ReportCard({
|
|||
openPanel({
|
||||
reportId,
|
||||
title: resolvedTitle,
|
||||
wordCount: resolvedWordCount ?? undefined,
|
||||
wordCount: parsed.data.report_metadata?.word_count ?? wordCount ?? undefined,
|
||||
shareToken,
|
||||
});
|
||||
}
|
||||
|
|
@ -210,7 +207,6 @@ function ReportCard({
|
|||
openPanel({
|
||||
reportId,
|
||||
title: metadata.title,
|
||||
wordCount: metadata.wordCount ?? undefined,
|
||||
shareToken,
|
||||
});
|
||||
};
|
||||
|
|
@ -233,10 +229,8 @@ function ReportCard({
|
|||
<span className="inline-block h-3 w-24 rounded bg-muted/60 animate-pulse" />
|
||||
) : (
|
||||
<>
|
||||
{metadata.wordCount != null && `${metadata.wordCount.toLocaleString()} words`}
|
||||
{metadata.wordCount != null && metadata.versionLabel && (
|
||||
<Dot className="inline size-4" />
|
||||
)}
|
||||
Markdown
|
||||
{metadata.versionLabel && <Dot className="inline size-4" />}
|
||||
{metadata.versionLabel}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Dot } from "lucide-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import * as pdfjsLib from "pdfjs-dist";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
|
@ -9,6 +10,7 @@ import { z } from "zod";
|
|||
import { openReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
|
||||
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||
import { getAuthHeaders } from "@/lib/auth-utils";
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
|
|
@ -32,6 +34,18 @@ const GenerateResumeResultSchema = z.object({
|
|||
error: z.string().nullish(),
|
||||
});
|
||||
|
||||
const ResumeVersionsResponseSchema = z.object({
|
||||
id: z.number(),
|
||||
versions: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
created_at: z.string().nullish(),
|
||||
})
|
||||
)
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
type GenerateResumeArgs = z.infer<typeof GenerateResumeArgsSchema>;
|
||||
type GenerateResumeResult = z.infer<typeof GenerateResumeResultSchema>;
|
||||
|
||||
|
|
@ -201,6 +215,7 @@ function ResumeCard({
|
|||
const autoOpenedRef = useRef(false);
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [thumbState, setThumbState] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [versionLabel, setVersionLabel] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const previewPath = shareToken
|
||||
|
|
@ -219,6 +234,35 @@ function ResumeCard({
|
|||
}
|
||||
}, [reportId, title, shareToken, autoOpen, isDesktop, openPanel]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchVersions = async () => {
|
||||
try {
|
||||
const url = shareToken
|
||||
? `/api/v1/public/${shareToken}/reports/${reportId}/content`
|
||||
: `/api/v1/reports/${reportId}/content`;
|
||||
const rawData = await baseApiService.get<unknown>(url);
|
||||
if (cancelled) return;
|
||||
const parsed = ResumeVersionsResponseSchema.safeParse(rawData);
|
||||
if (parsed.success) {
|
||||
const versions = parsed.data.versions;
|
||||
if (versions && versions.length > 1) {
|
||||
const idx = versions.findIndex((v) => v.id === reportId);
|
||||
if (idx >= 0) {
|
||||
setVersionLabel(`version ${idx + 1}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// silently ignore — version label is non-critical
|
||||
}
|
||||
};
|
||||
fetchVersions();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [reportId, shareToken]);
|
||||
|
||||
const onThumbLoad = useCallback(() => setThumbState("ready"), []);
|
||||
const onThumbError = useCallback(() => setThumbState("error"), []);
|
||||
|
||||
|
|
@ -243,8 +287,12 @@ function ResumeCard({
|
|||
className="w-full text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none cursor-pointer select-none"
|
||||
>
|
||||
<div className="px-5 pt-5 pb-4">
|
||||
<p className="text-base font-semibold text-foreground line-clamp-2">{title}</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">PDF</p>
|
||||
<p className="text-sm font-semibold text-foreground line-clamp-2">{title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
PDF
|
||||
{versionLabel && <Dot className="inline size-4" />}
|
||||
{versionLabel}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mx-5 h-px bg-border/50" />
|
||||
|
|
|
|||
|
|
@ -6,20 +6,19 @@ import { useEffect, useState } from "react";
|
|||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
function useIsTouchDevice() {
|
||||
const [isTouch, setIsTouch] = useState(false);
|
||||
function useCanHover() {
|
||||
const [canHover, setCanHover] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const update = () => setIsTouch(mql.matches);
|
||||
// Hover-capable pointers are a better cross-platform signal than viewport width.
|
||||
const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
|
||||
const update = () => setCanHover(mql.matches);
|
||||
update();
|
||||
mql.addEventListener("change", update);
|
||||
return () => mql.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
return isTouch;
|
||||
return canHover;
|
||||
}
|
||||
|
||||
function TooltipProvider({
|
||||
|
|
@ -42,14 +41,14 @@ function Tooltip({
|
|||
onOpenChange,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
const isMobile = useIsTouchDevice();
|
||||
const canHover = useCanHover();
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root
|
||||
data-slot="tooltip"
|
||||
open={isMobile ? false : open}
|
||||
onOpenChange={isMobile ? undefined : onOpenChange}
|
||||
open={canHover ? open : false}
|
||||
onOpenChange={canHover ? onOpenChange : undefined}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue