mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-10 20:35:17 +02:00
feat: enhance keyboard shortcut management and improve app responsiveness
- Updated the development script to include a build step before launching the app. - Refactored the registration of quick ask and autocomplete functionalities to be asynchronous, ensuring proper initialization. - Introduced IPC channels for getting and setting keyboard shortcuts, allowing users to customize their experience. - Enhanced the platform module to support better interaction with the Electron API for clipboard operations. - Improved the user interface for managing keyboard shortcuts in the settings dialog, providing a more intuitive experience.
This commit is contained in:
parent
e920923fa4
commit
49441233e7
30 changed files with 923 additions and 191 deletions
|
|
@ -4,7 +4,7 @@
|
|||
"description": "SurfSense Desktop App",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"pnpm --dir ../surfsense_web dev\" \"wait-on http://localhost:3000 && electron .\"",
|
||||
"dev": "pnpm build && concurrently -k \"pnpm --dir ../surfsense_web dev\" \"wait-on http://localhost:3000 && electron .\"",
|
||||
"build": "node scripts/build-electron.mjs",
|
||||
"pack:dir": "pnpm build && electron-builder --dir --config electron-builder.yml",
|
||||
"dist": "pnpm build && electron-builder --config electron-builder.yml",
|
||||
|
|
|
|||
|
|
@ -35,4 +35,7 @@ export const IPC_CHANNELS = {
|
|||
// Auth token sync across windows
|
||||
GET_AUTH_TOKENS: 'auth:get-tokens',
|
||||
SET_AUTH_TOKENS: 'auth:set-tokens',
|
||||
// Keyboard shortcut configuration
|
||||
GET_SHORTCUTS: 'shortcuts:get',
|
||||
SET_SHORTCUTS: 'shortcuts:set',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ import {
|
|||
browseFiles,
|
||||
readLocalFiles,
|
||||
} from '../modules/folder-watcher';
|
||||
import { getShortcuts, setShortcuts, type ShortcutConfig } from '../modules/shortcuts';
|
||||
import { reregisterQuickAsk } from '../modules/quick-ask';
|
||||
import { reregisterAutocomplete } from '../modules/autocomplete';
|
||||
|
||||
let authTokens: { bearer: string; refresh: string } | null = null;
|
||||
|
||||
|
|
@ -99,4 +102,13 @@ export function registerIpcHandlers(): void {
|
|||
ipcMain.handle(IPC_CHANNELS.GET_AUTH_TOKENS, () => {
|
||||
return authTokens;
|
||||
});
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.GET_SHORTCUTS, () => getShortcuts());
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.SET_SHORTCUTS, async (_event, config: Partial<ShortcutConfig>) => {
|
||||
const updated = await setShortcuts(config);
|
||||
if (config.quickAsk) await reregisterQuickAsk();
|
||||
if (config.autocomplete) await reregisterAutocomplete();
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ app.whenReady().then(async () => {
|
|||
}
|
||||
|
||||
createMainWindow('/dashboard');
|
||||
registerQuickAsk();
|
||||
registerAutocomplete();
|
||||
await registerQuickAsk();
|
||||
await registerAutocomplete();
|
||||
registerFolderWatcher();
|
||||
setupAutoUpdater();
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import { hasScreenRecordingPermission, requestAccessibility, requestScreenRecord
|
|||
import { getMainWindow } from '../window';
|
||||
import { captureScreen } from './screenshot';
|
||||
import { createSuggestionWindow, destroySuggestion, getSuggestionWindow } from './suggestion-window';
|
||||
import { getShortcuts } from '../shortcuts';
|
||||
|
||||
const SHORTCUT = 'CommandOrControl+Shift+Space';
|
||||
|
||||
let currentShortcut = '';
|
||||
let autocompleteEnabled = true;
|
||||
let savedClipboard = '';
|
||||
let sourceApp = '';
|
||||
|
|
@ -91,7 +91,12 @@ async function acceptAndInject(text: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
let ipcRegistered = false;
|
||||
|
||||
function registerIpcHandlers(): void {
|
||||
if (ipcRegistered) return;
|
||||
ipcRegistered = true;
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.ACCEPT_SUGGESTION, async (_event, text: string) => {
|
||||
await acceptAndInject(text);
|
||||
});
|
||||
|
|
@ -107,26 +112,39 @@ function registerIpcHandlers(): void {
|
|||
ipcMain.handle(IPC_CHANNELS.GET_AUTOCOMPLETE_ENABLED, () => autocompleteEnabled);
|
||||
}
|
||||
|
||||
export function registerAutocomplete(): void {
|
||||
registerIpcHandlers();
|
||||
function autocompleteHandler(): void {
|
||||
const sw = getSuggestionWindow();
|
||||
if (sw && !sw.isDestroyed()) {
|
||||
destroySuggestion();
|
||||
return;
|
||||
}
|
||||
triggerAutocomplete();
|
||||
}
|
||||
|
||||
const ok = globalShortcut.register(SHORTCUT, () => {
|
||||
const sw = getSuggestionWindow();
|
||||
if (sw && !sw.isDestroyed()) {
|
||||
destroySuggestion();
|
||||
return;
|
||||
}
|
||||
triggerAutocomplete();
|
||||
});
|
||||
async function registerShortcut(): Promise<void> {
|
||||
const shortcuts = await getShortcuts();
|
||||
currentShortcut = shortcuts.autocomplete;
|
||||
|
||||
const ok = globalShortcut.register(currentShortcut, autocompleteHandler);
|
||||
|
||||
if (!ok) {
|
||||
console.error(`[autocomplete] Failed to register shortcut ${SHORTCUT}`);
|
||||
console.error(`[autocomplete] Failed to register shortcut ${currentShortcut}`);
|
||||
} else {
|
||||
console.log(`[autocomplete] Registered shortcut ${SHORTCUT}`);
|
||||
console.log(`[autocomplete] Registered shortcut ${currentShortcut}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerAutocomplete(): Promise<void> {
|
||||
registerIpcHandlers();
|
||||
await registerShortcut();
|
||||
}
|
||||
|
||||
export function unregisterAutocomplete(): void {
|
||||
globalShortcut.unregister(SHORTCUT);
|
||||
if (currentShortcut) globalShortcut.unregister(currentShortcut);
|
||||
destroySuggestion();
|
||||
}
|
||||
|
||||
export async function reregisterAutocomplete(): Promise<void> {
|
||||
unregisterAutocomplete();
|
||||
await registerShortcut();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
import { execSync } from 'child_process';
|
||||
import { systemPreferences } from 'electron';
|
||||
|
||||
const EXEC_OPTS = { windowsHide: true } as const;
|
||||
|
||||
export function getFrontmostApp(): string {
|
||||
try {
|
||||
if (process.platform === 'darwin') {
|
||||
return execSync(
|
||||
'osascript -e \'tell application "System Events" to get name of first application process whose frontmost is true\''
|
||||
'osascript -e \'tell application "System Events" to get name of first application process whose frontmost is true\'',
|
||||
EXEC_OPTS,
|
||||
).toString().trim();
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return execSync(
|
||||
'powershell -command "Add-Type \'using System; using System.Runtime.InteropServices; public class W { [DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow(); }\'; (Get-Process | Where-Object { $_.MainWindowHandle -eq [W]::GetForegroundWindow() }).ProcessName"'
|
||||
'powershell -NoProfile -NonInteractive -command "Add-Type \'using System; using System.Runtime.InteropServices; public class W { [DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow(); }\'; (Get-Process | Where-Object { $_.MainWindowHandle -eq [W]::GetForegroundWindow() }).ProcessName"',
|
||||
EXEC_OPTS,
|
||||
).toString().trim();
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -21,9 +25,23 @@ export function getFrontmostApp(): string {
|
|||
|
||||
export function simulatePaste(): void {
|
||||
if (process.platform === 'darwin') {
|
||||
execSync('osascript -e \'tell application "System Events" to keystroke "v" using command down\'');
|
||||
execSync('osascript -e \'tell application "System Events" to keystroke "v" using command down\'', EXEC_OPTS);
|
||||
} else if (process.platform === 'win32') {
|
||||
execSync('powershell -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^v\')"');
|
||||
execSync('powershell -NoProfile -NonInteractive -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^v\')"', EXEC_OPTS);
|
||||
}
|
||||
}
|
||||
|
||||
export function simulateCopy(): boolean {
|
||||
try {
|
||||
if (process.platform === 'darwin') {
|
||||
execSync('osascript -e \'tell application "System Events" to keystroke "c" using command down\'', EXEC_OPTS);
|
||||
} else if (process.platform === 'win32') {
|
||||
execSync('powershell -NoProfile -NonInteractive -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^c\')"', EXEC_OPTS);
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[simulateCopy] Failed:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -36,12 +54,14 @@ export function getWindowTitle(): string {
|
|||
try {
|
||||
if (process.platform === 'darwin') {
|
||||
return execSync(
|
||||
'osascript -e \'tell application "System Events" to get title of front window of first application process whose frontmost is true\''
|
||||
'osascript -e \'tell application "System Events" to get title of front window of first application process whose frontmost is true\'',
|
||||
EXEC_OPTS,
|
||||
).toString().trim();
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return execSync(
|
||||
'powershell -command "(Get-Process | Where-Object { $_.MainWindowHandle -eq (Add-Type -MemberDefinition \'[DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow();\' -Name W -PassThru)::GetForegroundWindow() }).MainWindowTitle"'
|
||||
'powershell -NoProfile -NonInteractive -command "(Get-Process | Where-Object { $_.MainWindowHandle -eq (Add-Type -MemberDefinition \'[DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow();\' -Name W -PassThru)::GetForegroundWindow() }).MainWindowTitle"',
|
||||
EXEC_OPTS,
|
||||
).toString().trim();
|
||||
}
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } from 'electron';
|
||||
import path from 'path';
|
||||
import { IPC_CHANNELS } from '../ipc/channels';
|
||||
import { checkAccessibilityPermission, getFrontmostApp, simulatePaste } from './platform';
|
||||
import { checkAccessibilityPermission, getFrontmostApp, simulateCopy, simulatePaste } from './platform';
|
||||
import { getServerPort } from './server';
|
||||
import { getShortcuts } from './shortcuts';
|
||||
|
||||
const SHORTCUT = 'CommandOrControl+Option+S';
|
||||
let currentShortcut = '';
|
||||
let quickAskWindow: BrowserWindow | null = null;
|
||||
let pendingText = '';
|
||||
let pendingMode = '';
|
||||
|
|
@ -77,29 +78,52 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow {
|
|||
return quickAskWindow;
|
||||
}
|
||||
|
||||
export function registerQuickAsk(): void {
|
||||
const ok = globalShortcut.register(SHORTCUT, () => {
|
||||
if (quickAskWindow && !quickAskWindow.isDestroyed()) {
|
||||
destroyQuickAsk();
|
||||
return;
|
||||
}
|
||||
function openQuickAsk(text: string): void {
|
||||
pendingText = text;
|
||||
const cursor = screen.getCursorScreenPoint();
|
||||
const pos = clampToScreen(cursor.x, cursor.y, 450, 750);
|
||||
createQuickAskWindow(pos.x, pos.y);
|
||||
}
|
||||
|
||||
sourceApp = getFrontmostApp();
|
||||
savedClipboard = clipboard.readText();
|
||||
async function quickAskHandler(): Promise<void> {
|
||||
console.log('[quick-ask] Handler triggered');
|
||||
|
||||
const text = savedClipboard.trim();
|
||||
if (!text) return;
|
||||
|
||||
pendingText = text;
|
||||
const cursor = screen.getCursorScreenPoint();
|
||||
const pos = clampToScreen(cursor.x, cursor.y, 450, 750);
|
||||
createQuickAskWindow(pos.x, pos.y);
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
console.log(`Quick-ask: failed to register ${SHORTCUT}`);
|
||||
if (quickAskWindow && !quickAskWindow.isDestroyed()) {
|
||||
console.log('[quick-ask] Window already open, closing');
|
||||
destroyQuickAsk();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!checkAccessibilityPermission()) {
|
||||
console.log('[quick-ask] Accessibility permission denied');
|
||||
return;
|
||||
}
|
||||
|
||||
savedClipboard = clipboard.readText();
|
||||
console.log('[quick-ask] Saved clipboard length:', savedClipboard.length);
|
||||
|
||||
const copyOk = simulateCopy();
|
||||
console.log('[quick-ask] simulateCopy result:', copyOk);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
const afterCopy = clipboard.readText();
|
||||
const selected = afterCopy.trim();
|
||||
console.log('[quick-ask] Clipboard after copy length:', afterCopy.length, 'changed:', afterCopy !== savedClipboard);
|
||||
|
||||
const text = selected || savedClipboard.trim();
|
||||
|
||||
sourceApp = getFrontmostApp();
|
||||
console.log('[quick-ask] Source app:', sourceApp, '| Opening Quick Ask with', text.length, 'chars', selected ? '(selected)' : text ? '(clipboard fallback)' : '(empty)');
|
||||
openQuickAsk(text);
|
||||
}
|
||||
|
||||
let ipcRegistered = false;
|
||||
|
||||
function registerIpcHandlers(): void {
|
||||
if (ipcRegistered) return;
|
||||
ipcRegistered = true;
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.QUICK_ASK_TEXT, () => {
|
||||
const text = pendingText;
|
||||
pendingText = '';
|
||||
|
|
@ -136,6 +160,24 @@ export function registerQuickAsk(): void {
|
|||
});
|
||||
}
|
||||
|
||||
export function unregisterQuickAsk(): void {
|
||||
globalShortcut.unregister(SHORTCUT);
|
||||
async function registerShortcut(): Promise<void> {
|
||||
const shortcuts = await getShortcuts();
|
||||
currentShortcut = shortcuts.quickAsk;
|
||||
|
||||
const ok = globalShortcut.register(currentShortcut, () => { quickAskHandler(); });
|
||||
console.log(`[quick-ask] Register ${currentShortcut}: ${ok ? 'OK' : 'FAILED'}`);
|
||||
}
|
||||
|
||||
export async function registerQuickAsk(): Promise<void> {
|
||||
registerIpcHandlers();
|
||||
await registerShortcut();
|
||||
}
|
||||
|
||||
export function unregisterQuickAsk(): void {
|
||||
if (currentShortcut) globalShortcut.unregister(currentShortcut);
|
||||
}
|
||||
|
||||
export async function reregisterQuickAsk(): Promise<void> {
|
||||
unregisterQuickAsk();
|
||||
await registerShortcut();
|
||||
}
|
||||
|
|
|
|||
42
surfsense_desktop/src/modules/shortcuts.ts
Normal file
42
surfsense_desktop/src/modules/shortcuts.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
export interface ShortcutConfig {
|
||||
quickAsk: string;
|
||||
autocomplete: string;
|
||||
}
|
||||
|
||||
const DEFAULTS: ShortcutConfig = {
|
||||
quickAsk: 'CommandOrControl+Alt+S',
|
||||
autocomplete: 'CommandOrControl+Shift+Space',
|
||||
};
|
||||
|
||||
const STORE_KEY = 'shortcuts';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- lazily imported ESM module; matches folder-watcher.ts pattern
|
||||
let store: any = null;
|
||||
|
||||
async function getStore() {
|
||||
if (!store) {
|
||||
const { default: Store } = await import('electron-store');
|
||||
store = new Store({
|
||||
name: 'keyboard-shortcuts',
|
||||
defaults: { [STORE_KEY]: DEFAULTS },
|
||||
});
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
export async function getShortcuts(): Promise<ShortcutConfig> {
|
||||
const s = await getStore();
|
||||
const stored = s.get(STORE_KEY) as Partial<ShortcutConfig> | undefined;
|
||||
return { ...DEFAULTS, ...stored };
|
||||
}
|
||||
|
||||
export async function setShortcuts(config: Partial<ShortcutConfig>): Promise<ShortcutConfig> {
|
||||
const s = await getStore();
|
||||
const current = (s.get(STORE_KEY) as ShortcutConfig) ?? DEFAULTS;
|
||||
const merged = { ...current, ...config };
|
||||
s.set(STORE_KEY, merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function getDefaults(): ShortcutConfig {
|
||||
return { ...DEFAULTS };
|
||||
}
|
||||
|
|
@ -73,4 +73,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||
getAuthTokens: () => ipcRenderer.invoke(IPC_CHANNELS.GET_AUTH_TOKENS),
|
||||
setAuthTokens: (bearer: string, refresh: string) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.SET_AUTH_TOKENS, { bearer, refresh }),
|
||||
|
||||
// Keyboard shortcut configuration
|
||||
getShortcuts: () => ipcRenderer.invoke(IPC_CHANNELS.GET_SHORTCUTS),
|
||||
setShortcuts: (config: Record<string, string>) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.SET_SHORTCUTS, config),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,30 +1,54 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Clipboard, Sparkles } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
DEFAULT_SHORTCUTS,
|
||||
ShortcutRecorder,
|
||||
} from "@/components/desktop/shortcut-recorder";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
|
||||
export function DesktopContent() {
|
||||
const [isElectron, setIsElectron] = useState(false);
|
||||
const api = useElectronAPI();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
|
||||
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
|
||||
const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) {
|
||||
if (!api) {
|
||||
setLoading(false);
|
||||
setShortcutsLoaded(true);
|
||||
return;
|
||||
}
|
||||
setIsElectron(true);
|
||||
|
||||
window.electronAPI.getAutocompleteEnabled().then((val) => {
|
||||
setEnabled(val);
|
||||
let mounted = true;
|
||||
|
||||
Promise.all([
|
||||
api.getAutocompleteEnabled(),
|
||||
api.getShortcuts?.() ?? Promise.resolve(null),
|
||||
]).then(([autoEnabled, config]) => {
|
||||
if (!mounted) return;
|
||||
setEnabled(autoEnabled);
|
||||
if (config) setShortcuts(config);
|
||||
setLoading(false);
|
||||
setShortcutsLoaded(true);
|
||||
}).catch(() => {
|
||||
if (!mounted) return;
|
||||
setLoading(false);
|
||||
setShortcutsLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!isElectron) {
|
||||
return () => { mounted = false; };
|
||||
}, [api]);
|
||||
|
||||
if (!api) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
|
@ -44,11 +68,68 @@ export function DesktopContent() {
|
|||
|
||||
const handleToggle = async (checked: boolean) => {
|
||||
setEnabled(checked);
|
||||
await window.electronAPI!.setAutocompleteEnabled(checked);
|
||||
await api.setAutocompleteEnabled(checked);
|
||||
};
|
||||
|
||||
const updateShortcut = (key: "quickAsk" | "autocomplete", accelerator: string) => {
|
||||
setShortcuts((prev) => {
|
||||
const updated = { ...prev, [key]: accelerator };
|
||||
api.setShortcuts?.({ [key]: accelerator }).catch(() => {
|
||||
toast.error("Failed to update shortcut");
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
toast.success("Shortcut updated");
|
||||
};
|
||||
|
||||
const resetShortcut = (key: "quickAsk" | "autocomplete") => {
|
||||
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Keyboard Shortcuts */}
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<CardTitle className="text-base md:text-lg">Keyboard Shortcuts</CardTitle>
|
||||
<CardDescription className="text-xs md:text-sm">
|
||||
Customize the global keyboard shortcuts for desktop features.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||
{shortcutsLoaded ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.quickAsk}
|
||||
onChange={(accel) => updateShortcut("quickAsk", accel)}
|
||||
onReset={() => resetShortcut("quickAsk")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.quickAsk}
|
||||
label="Quick Ask"
|
||||
description="Copy selected text and ask AI about it"
|
||||
icon={Clipboard}
|
||||
/>
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.autocomplete}
|
||||
onChange={(accel) => updateShortcut("autocomplete", accel)}
|
||||
onReset={() => resetShortcut("autocomplete")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.autocomplete}
|
||||
label="Autocomplete"
|
||||
description="Get AI writing suggestions from a screenshot"
|
||||
icon={Sparkles}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Click a shortcut and press a new key combination to change it.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center py-4">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Autocomplete Toggle */}
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<CardTitle className="text-base md:text-lg">Autocomplete</CardTitle>
|
||||
|
|
|
|||
258
surfsense_web/app/desktop/login/page.tsx
Normal file
258
surfsense_web/app/desktop/login/page.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
"use client";
|
||||
|
||||
import { IconBrandGoogleFilled } from "@tabler/icons-react";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Keyboard,
|
||||
Clipboard,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||
import {
|
||||
DEFAULT_SHORTCUTS,
|
||||
ShortcutRecorder,
|
||||
} from "@/components/desktop/shortcut-recorder";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
|
||||
|
||||
const isGoogleAuth = AUTH_TYPE === "GOOGLE";
|
||||
|
||||
export default function DesktopLoginPage() {
|
||||
const router = useRouter();
|
||||
const api = useElectronAPI();
|
||||
const [{ mutateAsync: login, isPending: isLoggingIn }] =
|
||||
useAtom(loginMutationAtom);
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
|
||||
const [shortcutsLoaded, setShortcutsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api?.getShortcuts) {
|
||||
setShortcutsLoaded(true);
|
||||
return;
|
||||
}
|
||||
api.getShortcuts().then((config) => {
|
||||
if (config) setShortcuts(config);
|
||||
setShortcutsLoaded(true);
|
||||
}).catch(() => setShortcutsLoaded(true));
|
||||
}, [api]);
|
||||
|
||||
const updateShortcut = useCallback(
|
||||
(key: "quickAsk" | "autocomplete", accelerator: string) => {
|
||||
setShortcuts((prev) => {
|
||||
const updated = { ...prev, [key]: accelerator };
|
||||
api?.setShortcuts?.({ [key]: accelerator }).catch(() => {
|
||||
toast.error("Failed to update shortcut");
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
toast.success("Shortcut updated");
|
||||
},
|
||||
[api]
|
||||
);
|
||||
|
||||
const resetShortcut = useCallback(
|
||||
(key: "quickAsk" | "autocomplete") => {
|
||||
updateShortcut(key, DEFAULT_SHORTCUTS[key]);
|
||||
},
|
||||
[updateShortcut]
|
||||
);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
|
||||
};
|
||||
|
||||
const handleLocalLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoginError(null);
|
||||
|
||||
try {
|
||||
const data = await login({
|
||||
username: email,
|
||||
password,
|
||||
grant_type: "password",
|
||||
});
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
sessionStorage.setItem("login_success_tracked", "true");
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
router.push(`/auth/callback?token=${data.access_token}`);
|
||||
}, 300);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setLoginError(err.message);
|
||||
} else {
|
||||
setLoginError("Login failed. Please check your credentials.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className="absolute -top-1/2 left-1/2 size-[800px] -translate-x-1/2 rounded-full opacity-[0.03]"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle, hsl(var(--primary)) 0%, transparent 70%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="relative w-full max-w-md shadow-lg">
|
||||
<CardHeader className="items-center text-center pb-4">
|
||||
<Image
|
||||
src="/icon-128.svg"
|
||||
className="select-none dark:invert size-14 rounded-md mb-2"
|
||||
alt="SurfSense"
|
||||
width={56}
|
||||
height={56}
|
||||
priority
|
||||
/>
|
||||
<CardTitle className="text-xl">Welcome to SurfSense Desktop App</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your shortcuts, then sign in to get started.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
{/* ---- Shortcuts Section (first) ---- */}
|
||||
{shortcutsLoaded ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground mb-1">
|
||||
<Keyboard className="size-3" />
|
||||
Keyboard Shortcuts
|
||||
</div>
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.quickAsk}
|
||||
onChange={(accel) => updateShortcut("quickAsk", accel)}
|
||||
onReset={() => resetShortcut("quickAsk")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.quickAsk}
|
||||
label="Quick Ask"
|
||||
description="Copy selected text and ask AI about it"
|
||||
icon={Clipboard}
|
||||
/>
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.autocomplete}
|
||||
onChange={(accel) => updateShortcut("autocomplete", accel)}
|
||||
onReset={() => resetShortcut("autocomplete")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.autocomplete}
|
||||
label="Autocomplete"
|
||||
description="Get AI writing suggestions from a screenshot"
|
||||
icon={Sparkles}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground text-center">
|
||||
Click a shortcut and press a new key combination to change it.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center py-4">
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Divider ---- */}
|
||||
<Separator />
|
||||
|
||||
{/* ---- Auth Section (second) ---- */}
|
||||
{isGoogleAuth ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full gap-2 py-5"
|
||||
onClick={handleGoogleLogin}
|
||||
>
|
||||
<IconBrandGoogleFilled className="size-5" />
|
||||
Continue with Google
|
||||
</Button>
|
||||
) : (
|
||||
<form onSubmit={handleLocalLogin} className="flex flex-col gap-4">
|
||||
{loginError && (
|
||||
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{loginError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoggingIn}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoggingIn}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="size-4" />
|
||||
) : (
|
||||
<Eye className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isLoggingIn} className="mt-1">
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="text-primary-foreground" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
|||
import { Logo } from "@/components/Logo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
|
||||
type PermissionStatus = "authorized" | "denied" | "not determined" | "restricted" | "limited";
|
||||
|
||||
|
|
@ -57,19 +58,18 @@ function StatusBadge({ status }: { status: PermissionStatus }) {
|
|||
|
||||
export default function DesktopPermissionsPage() {
|
||||
const router = useRouter();
|
||||
const api = useElectronAPI();
|
||||
const [permissions, setPermissions] = useState<PermissionsStatus | null>(null);
|
||||
const [isElectron, setIsElectron] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return;
|
||||
setIsElectron(true);
|
||||
if (!api) return;
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const isResolved = (s: string) => s === "authorized" || s === "restricted";
|
||||
|
||||
const poll = async () => {
|
||||
const status = await window.electronAPI!.getPermissionsStatus();
|
||||
const status = await api.getPermissionsStatus();
|
||||
setPermissions(status);
|
||||
|
||||
if (isResolved(status.accessibility) && isResolved(status.screenRecording)) {
|
||||
|
|
@ -80,9 +80,9 @@ export default function DesktopPermissionsPage() {
|
|||
poll();
|
||||
interval = setInterval(poll, 2000);
|
||||
return () => { if (interval) clearInterval(interval); };
|
||||
}, []);
|
||||
}, [api]);
|
||||
|
||||
if (!isElectron) {
|
||||
if (!api) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-background">
|
||||
<p className="text-muted-foreground">This page is only available in the desktop app.</p>
|
||||
|
|
@ -102,15 +102,15 @@ export default function DesktopPermissionsPage() {
|
|||
|
||||
const handleRequest = async (action: string) => {
|
||||
if (action === "requestScreenRecording") {
|
||||
await window.electronAPI!.requestScreenRecording();
|
||||
await api.requestScreenRecording();
|
||||
} else if (action === "requestAccessibility") {
|
||||
await window.electronAPI!.requestAccessibility();
|
||||
await api.requestAccessibility();
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (allGranted) {
|
||||
window.electronAPI!.restartApp();
|
||||
api.restartApp();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { getBearerToken, ensureTokensFromElectron } from "@/lib/auth-utils";
|
||||
|
||||
type SSEEvent =
|
||||
|
|
@ -34,26 +35,27 @@ function friendlyError(raw: string | number): string {
|
|||
const AUTO_DISMISS_MS = 3000;
|
||||
|
||||
export default function SuggestionPage() {
|
||||
const api = useElectronAPI();
|
||||
const [suggestion, setSuggestion] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isDesktop, setIsDesktop] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const isDesktop = !!api?.onAutocompleteContext;
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI?.onAutocompleteContext) {
|
||||
setIsDesktop(false);
|
||||
if (!api?.onAutocompleteContext) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) return;
|
||||
const timer = setTimeout(() => {
|
||||
window.electronAPI?.dismissSuggestion?.();
|
||||
api?.dismissSuggestion?.();
|
||||
}, AUTO_DISMISS_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [error]);
|
||||
}, [error, api]);
|
||||
|
||||
const fetchSuggestion = useCallback(
|
||||
async (screenshot: string, searchSpaceId: string, appName?: string, windowTitle?: string) => {
|
||||
|
|
@ -153,9 +155,9 @@ export default function SuggestionPage() {
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI?.onAutocompleteContext) return;
|
||||
if (!api?.onAutocompleteContext) return;
|
||||
|
||||
const cleanup = window.electronAPI.onAutocompleteContext((data) => {
|
||||
const cleanup = api.onAutocompleteContext((data) => {
|
||||
const searchSpaceId = data.searchSpaceId || "1";
|
||||
if (data.screenshot) {
|
||||
fetchSuggestion(data.screenshot, searchSpaceId, data.appName, data.windowTitle);
|
||||
|
|
@ -163,7 +165,7 @@ export default function SuggestionPage() {
|
|||
});
|
||||
|
||||
return cleanup;
|
||||
}, [fetchSuggestion]);
|
||||
}, [fetchSuggestion, api]);
|
||||
|
||||
if (!isDesktop) {
|
||||
return (
|
||||
|
|
@ -197,12 +199,12 @@ export default function SuggestionPage() {
|
|||
|
||||
const handleAccept = () => {
|
||||
if (suggestion) {
|
||||
window.electronAPI?.acceptSuggestion?.(suggestion);
|
||||
api?.acceptSuggestion?.(suggestion);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
window.electronAPI?.dismissSuggestion?.();
|
||||
api?.dismissSuggestion?.();
|
||||
};
|
||||
|
||||
if (!suggestion) return null;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { ZeroProvider } from "@/components/providers/ZeroProvider";
|
|||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { LocaleProvider } from "@/contexts/LocaleContext";
|
||||
import { PlatformProvider } from "@/contexts/platform-context";
|
||||
import { ReactQueryClientProvider } from "@/lib/query-client/query-client.provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -139,15 +140,17 @@ export default function RootLayout({
|
|||
disableTransitionOnChange
|
||||
defaultTheme="system"
|
||||
>
|
||||
<RootProvider>
|
||||
<ReactQueryClientProvider>
|
||||
<ZeroProvider>
|
||||
<GlobalLoadingProvider>{children}</GlobalLoadingProvider>
|
||||
</ZeroProvider>
|
||||
</ReactQueryClientProvider>
|
||||
<Toaster />
|
||||
<AnnouncementToastProvider />
|
||||
</RootProvider>
|
||||
<PlatformProvider>
|
||||
<RootProvider>
|
||||
<ReactQueryClientProvider>
|
||||
<ZeroProvider>
|
||||
<GlobalLoadingProvider>{children}</GlobalLoadingProvider>
|
||||
</ZeroProvider>
|
||||
</ReactQueryClientProvider>
|
||||
<Toaster />
|
||||
<AnnouncementToastProvider />
|
||||
</RootProvider>
|
||||
</PlatformProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</LocaleProvider>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { logout } from "@/lib/auth-utils";
|
||||
import { getLoginPath, logout } from "@/lib/auth-utils";
|
||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||
|
||||
export function UserDropdown({
|
||||
|
|
@ -33,22 +33,19 @@ export function UserDropdown({
|
|||
if (isLoggingOut) return;
|
||||
setIsLoggingOut(true);
|
||||
try {
|
||||
// Track logout event and reset PostHog identity
|
||||
trackLogout();
|
||||
resetUser();
|
||||
|
||||
// Revoke refresh token on server and clear all tokens from localStorage
|
||||
await logout();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/";
|
||||
window.location.href = getLoginPath();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during logout:", error);
|
||||
// Even if there's an error, try to clear tokens and redirect
|
||||
await logout();
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/";
|
||||
window.location.href = getLoginPath();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ import {
|
|||
} from "@/components/ui/drawer";
|
||||
import { useComments } from "@/hooks/use-comments";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Dynamically import video presentation tool to avoid loading Babel and Remotion in main bundle
|
||||
|
|
@ -463,16 +464,17 @@ export const AssistantMessage: FC = () => {
|
|||
const AssistantActionBar: FC = () => {
|
||||
const isLast = useAuiState((s) => s.message.isLast);
|
||||
const aui = useAui();
|
||||
const api = useElectronAPI();
|
||||
const [quickAskMode, setQuickAskMode] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLast || !window.electronAPI?.getQuickAskMode) return;
|
||||
window.electronAPI.getQuickAskMode().then((mode) => {
|
||||
if (!isLast || !api?.getQuickAskMode) return;
|
||||
api.getQuickAskMode().then((mode) => {
|
||||
if (mode) setQuickAskMode(mode);
|
||||
});
|
||||
}, [isLast]);
|
||||
}, [isLast, api]);
|
||||
|
||||
const isTransform = isLast && !!window.electronAPI?.replaceText && quickAskMode === "transform";
|
||||
const isTransform = isLast && !!api?.replaceText && quickAskMode === "transform";
|
||||
|
||||
return (
|
||||
<ActionBarPrimitive.Root
|
||||
|
|
@ -508,7 +510,7 @@ const AssistantActionBar: FC = () => {
|
|||
type="button"
|
||||
onClick={() => {
|
||||
const text = aui.message().getCopyText();
|
||||
window.electronAPI?.replaceText(text);
|
||||
api?.replaceText(text);
|
||||
}}
|
||||
className="ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import type { FC } from "react";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { usePlatform } from "@/hooks/use-platform";
|
||||
import { isSelfHosted } from "@/lib/env-config";
|
||||
import { ConnectorCard } from "../components/connector-card";
|
||||
import {
|
||||
|
|
@ -74,9 +75,8 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
onManage,
|
||||
onViewAccountsList,
|
||||
}) => {
|
||||
// Check if self-hosted mode (for showing self-hosted only connectors)
|
||||
const selfHosted = isSelfHosted();
|
||||
const isDesktop = typeof window !== "undefined" && !!window.electronAPI;
|
||||
const { isDesktop } = usePlatform();
|
||||
|
||||
const matchesSearch = (title: string, description: string) =>
|
||||
title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export interface MentionedDocument {
|
|||
export interface InlineMentionEditorRef {
|
||||
focus: () => void;
|
||||
clear: () => void;
|
||||
setText: (text: string) => void;
|
||||
getText: () => string;
|
||||
getMentionedDocuments: () => MentionedDocument[];
|
||||
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
|
||||
|
|
@ -397,6 +398,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Replace editor content with plain text and place cursor at end
|
||||
const setText = useCallback(
|
||||
(text: string) => {
|
||||
if (!editorRef.current) return;
|
||||
editorRef.current.innerText = text;
|
||||
const empty = text.length === 0;
|
||||
setIsEmpty(empty);
|
||||
onChange?.(text, Array.from(mentionedDocs.values()));
|
||||
focusAtEnd();
|
||||
},
|
||||
[focusAtEnd, onChange, mentionedDocs]
|
||||
);
|
||||
|
||||
const setDocumentChipStatus = useCallback(
|
||||
(
|
||||
docId: number,
|
||||
|
|
@ -469,6 +483,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
useImperativeHandle(ref, () => ({
|
||||
focus: () => editorRef.current?.focus(),
|
||||
clear,
|
||||
setText,
|
||||
getText,
|
||||
getMentionedDocuments,
|
||||
insertDocumentChip,
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ import type { Document } from "@/contracts/types/document.types";
|
|||
import { useBatchCommentsPreload } from "@/hooks/use-comments";
|
||||
import { useCommentsSync } from "@/hooks/use-comments-sync";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Placeholder texts that cycle in new chats when input is empty */
|
||||
|
|
@ -362,18 +363,19 @@ const Composer: FC = () => {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const electronAPI = useElectronAPI();
|
||||
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
|
||||
const clipboardLoadedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI || clipboardLoadedRef.current) return;
|
||||
if (!electronAPI || clipboardLoadedRef.current) return;
|
||||
clipboardLoadedRef.current = true;
|
||||
window.electronAPI.getQuickAskText().then((text) => {
|
||||
electronAPI.getQuickAskText().then((text) => {
|
||||
if (text) {
|
||||
setClipboardInitialText(text);
|
||||
setShowPromptPicker(true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
}, [electronAPI]);
|
||||
|
||||
const isThreadEmpty = useAuiState(({ thread }) => thread.isEmpty);
|
||||
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||
|
|
@ -504,34 +506,28 @@ const Composer: FC = () => {
|
|||
: userText
|
||||
? `${action.prompt}\n\n${userText}`
|
||||
: action.prompt;
|
||||
editorRef.current?.setText(finalPrompt);
|
||||
aui.composer().setText(finalPrompt);
|
||||
aui.composer().send();
|
||||
editorRef.current?.clear();
|
||||
setShowPromptPicker(false);
|
||||
setActionQuery("");
|
||||
setMentionedDocuments([]);
|
||||
setSidebarDocs([]);
|
||||
},
|
||||
[actionQuery, aui, setMentionedDocuments, setSidebarDocs]
|
||||
[actionQuery, aui]
|
||||
);
|
||||
|
||||
const handleQuickAskSelect = useCallback(
|
||||
(action: { name: string; prompt: string; mode: "transform" | "explore" }) => {
|
||||
if (!clipboardInitialText) return;
|
||||
window.electronAPI?.setQuickAskMode(action.mode);
|
||||
electronAPI?.setQuickAskMode(action.mode);
|
||||
const finalPrompt = action.prompt.includes("{selection}")
|
||||
? action.prompt.replace("{selection}", () => clipboardInitialText)
|
||||
: `${action.prompt}\n\n${clipboardInitialText}`;
|
||||
editorRef.current?.setText(finalPrompt);
|
||||
aui.composer().setText(finalPrompt);
|
||||
aui.composer().send();
|
||||
editorRef.current?.clear();
|
||||
setShowPromptPicker(false);
|
||||
setActionQuery("");
|
||||
setClipboardInitialText(undefined);
|
||||
setMentionedDocuments([]);
|
||||
setSidebarDocs([]);
|
||||
},
|
||||
[clipboardInitialText, aui, setMentionedDocuments, setSidebarDocs]
|
||||
[clipboardInitialText, electronAPI, aui]
|
||||
);
|
||||
|
||||
// Keyboard navigation for document/action picker (arrow keys, Enter, Escape)
|
||||
|
|
|
|||
168
surfsense_web/components/desktop/shortcut-recorder.tsx
Normal file
168
surfsense_web/components/desktop/shortcut-recorder.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"use client";
|
||||
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accelerator ↔ display helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function keyEventToAccelerator(e: React.KeyboardEvent): string | null {
|
||||
const parts: string[] = [];
|
||||
if (e.ctrlKey || e.metaKey) parts.push("CommandOrControl");
|
||||
if (e.altKey) parts.push("Alt");
|
||||
if (e.shiftKey) parts.push("Shift");
|
||||
|
||||
const key = e.key;
|
||||
if (["Control", "Meta", "Alt", "Shift"].includes(key)) return null;
|
||||
|
||||
if (key === " ") parts.push("Space");
|
||||
else if (key.length === 1) parts.push(key.toUpperCase());
|
||||
else parts.push(key);
|
||||
|
||||
if (parts.length < 2) return null;
|
||||
return parts.join("+");
|
||||
}
|
||||
|
||||
export function acceleratorToDisplay(accel: string): string[] {
|
||||
if (!accel) return [];
|
||||
return accel.split("+").map((part) => {
|
||||
if (part === "CommandOrControl") return "Ctrl";
|
||||
if (part === "Space") return "Space";
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
export const DEFAULT_SHORTCUTS = {
|
||||
quickAsk: "CommandOrControl+Alt+S",
|
||||
autocomplete: "CommandOrControl+Shift+Space",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kbd pill component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function Kbd({
|
||||
keys,
|
||||
className,
|
||||
}: {
|
||||
keys: string[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("inline-flex items-center gap-1", className)}>
|
||||
{keys.map((key) => (
|
||||
<kbd
|
||||
key={key}
|
||||
className={cn(
|
||||
"inline-flex h-7 min-w-7 items-center justify-center rounded-md border bg-muted px-1.5 font-mono text-xs font-medium text-muted-foreground shadow-sm",
|
||||
key.length > 3 && "px-2"
|
||||
)}
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shortcut recorder component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ShortcutRecorder({
|
||||
value,
|
||||
onChange,
|
||||
onReset,
|
||||
defaultValue,
|
||||
label,
|
||||
description,
|
||||
icon: Icon,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (accelerator: string) => void;
|
||||
onReset: () => void;
|
||||
defaultValue: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ElementType;
|
||||
}) {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const inputRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (!recording) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === "Escape") {
|
||||
setRecording(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const accel = keyEventToAccelerator(e);
|
||||
if (accel) {
|
||||
onChange(accel);
|
||||
setRecording(false);
|
||||
}
|
||||
},
|
||||
[recording, onChange]
|
||||
);
|
||||
|
||||
const displayKeys = acceleratorToDisplay(value);
|
||||
const isDefault = value === defaultValue;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border bg-background p-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium leading-none">{label}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground truncate">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{!isDefault && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
onClick={onReset}
|
||||
title="Reset to default"
|
||||
>
|
||||
<RotateCcw />
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
ref={inputRef}
|
||||
type="button"
|
||||
onClick={() => setRecording(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => setRecording(false)}
|
||||
className={cn(
|
||||
"flex h-9 items-center gap-1 rounded-md border px-3 text-sm transition-all focus:outline-none",
|
||||
recording
|
||||
? "border-primary bg-primary/5 ring-2 ring-primary/20"
|
||||
: "border-input bg-muted/50 hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{recording ? (
|
||||
<span className="text-xs text-primary animate-pulse">
|
||||
Press keys...
|
||||
</span>
|
||||
) : (
|
||||
<Kbd keys={displayKeys} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -55,7 +55,7 @@ import { useInbox } from "@/hooks/use-inbox";
|
|||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { logout } from "@/lib/auth-utils";
|
||||
import { getLoginPath, logout } from "@/lib/auth-utils";
|
||||
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
|
||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
|
@ -600,12 +600,12 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
|
|||
await logout();
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
router.push("/");
|
||||
router.push(getLoginPath());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during logout:", error);
|
||||
await logout();
|
||||
router.push("/");
|
||||
router.push(getLoginPath());
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,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 { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { foldersApiService } from "@/lib/apis/folders-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
|
@ -84,6 +85,7 @@ export function DocumentsSidebar({
|
|||
const tSidebar = useTranslations("sidebar");
|
||||
const params = useParams();
|
||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||
const electronAPI = useElectronAPI();
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
|
||||
const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom);
|
||||
|
|
@ -97,11 +99,11 @@ export function DocumentsSidebar({
|
|||
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
const api = typeof window !== "undefined" ? window.electronAPI : null;
|
||||
if (!api?.getWatchedFolders) return;
|
||||
if (!electronAPI?.getWatchedFolders) return;
|
||||
const api = electronAPI;
|
||||
|
||||
async function loadWatchedIds() {
|
||||
const folders = await api!.getWatchedFolders();
|
||||
const folders = await api.getWatchedFolders();
|
||||
|
||||
if (folders.length === 0) {
|
||||
try {
|
||||
|
|
@ -109,7 +111,7 @@ export function DocumentsSidebar({
|
|||
for (const bf of backendFolders) {
|
||||
const meta = bf.metadata as Record<string, unknown> | null;
|
||||
if (!meta?.watched || !meta.folder_path) continue;
|
||||
await api!.addWatchedFolder({
|
||||
await api.addWatchedFolder({
|
||||
path: meta.folder_path as string,
|
||||
name: bf.name,
|
||||
rootFolderId: bf.id,
|
||||
|
|
@ -119,7 +121,7 @@ export function DocumentsSidebar({
|
|||
active: true,
|
||||
});
|
||||
}
|
||||
const recovered = await api!.getWatchedFolders();
|
||||
const recovered = await api.getWatchedFolders();
|
||||
const ids = new Set(
|
||||
recovered.filter((f) => f.rootFolderId != null).map((f) => f.rootFolderId as number)
|
||||
);
|
||||
|
|
@ -137,7 +139,7 @@ export function DocumentsSidebar({
|
|||
}
|
||||
|
||||
loadWatchedIds();
|
||||
}, [searchSpaceId]);
|
||||
}, [searchSpaceId, electronAPI]);
|
||||
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
|
||||
|
||||
const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom);
|
||||
|
|
@ -276,10 +278,9 @@ export function DocumentsSidebar({
|
|||
|
||||
const handleRescanFolder = useCallback(
|
||||
async (folder: FolderDisplay) => {
|
||||
const api = window.electronAPI;
|
||||
if (!api) return;
|
||||
if (!electronAPI) return;
|
||||
|
||||
const watchedFolders = await api.getWatchedFolders();
|
||||
const watchedFolders = await electronAPI.getWatchedFolders();
|
||||
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
||||
if (!matched) {
|
||||
toast.error("This folder is not being watched");
|
||||
|
|
@ -298,28 +299,27 @@ export function DocumentsSidebar({
|
|||
toast.error((err as Error)?.message || "Failed to re-scan folder");
|
||||
}
|
||||
},
|
||||
[searchSpaceId]
|
||||
[searchSpaceId, electronAPI]
|
||||
);
|
||||
|
||||
const handleStopWatching = useCallback(async (folder: FolderDisplay) => {
|
||||
const api = window.electronAPI;
|
||||
if (!api) return;
|
||||
if (!electronAPI) return;
|
||||
|
||||
const watchedFolders = await api.getWatchedFolders();
|
||||
const watchedFolders = await electronAPI.getWatchedFolders();
|
||||
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
||||
if (!matched) {
|
||||
toast.error("This folder is not being watched");
|
||||
return;
|
||||
}
|
||||
|
||||
await api.removeWatchedFolder(matched.path);
|
||||
await electronAPI.removeWatchedFolder(matched.path);
|
||||
try {
|
||||
await foldersApiService.stopWatching(folder.id);
|
||||
} catch (err) {
|
||||
console.error("[DocumentsSidebar] Failed to clear watched metadata:", err);
|
||||
}
|
||||
toast.success(`Stopped watching: ${matched.name}`);
|
||||
}, []);
|
||||
}, [electronAPI]);
|
||||
|
||||
const handleRenameFolder = useCallback(async (folder: FolderDisplay, newName: string) => {
|
||||
try {
|
||||
|
|
@ -333,12 +333,11 @@ export function DocumentsSidebar({
|
|||
const handleDeleteFolder = useCallback(async (folder: FolderDisplay) => {
|
||||
if (!confirm(`Delete folder "${folder.name}" and all its contents?`)) return;
|
||||
try {
|
||||
const api = window.electronAPI;
|
||||
if (api) {
|
||||
const watchedFolders = await api.getWatchedFolders();
|
||||
if (electronAPI) {
|
||||
const watchedFolders = await electronAPI.getWatchedFolders();
|
||||
const matched = watchedFolders.find((wf) => wf.rootFolderId === folder.id);
|
||||
if (matched) {
|
||||
await api.removeWatchedFolder(matched.path);
|
||||
await electronAPI.removeWatchedFolder(matched.path);
|
||||
}
|
||||
}
|
||||
await foldersApiService.deleteFolder(folder.id);
|
||||
|
|
@ -346,7 +345,7 @@ export function DocumentsSidebar({
|
|||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || "Failed to delete folder");
|
||||
}
|
||||
}, []);
|
||||
}, [electronAPI]);
|
||||
|
||||
const handleMoveFolder = useCallback(
|
||||
(folder: FolderDisplay) => {
|
||||
|
|
|
|||
16
surfsense_web/components/platform-gate.tsx
Normal file
16
surfsense_web/components/platform-gate.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { usePlatform } from "@/hooks/use-platform";
|
||||
|
||||
export function DesktopOnly({ children }: { children: ReactNode }) {
|
||||
const { isDesktop } = usePlatform();
|
||||
if (!isDesktop) return null;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function WebOnly({ children }: { children: ReactNode }) {
|
||||
const { isWeb } = usePlatform();
|
||||
if (!isWeb) return null;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { useAtom } from "jotai";
|
||||
import { Globe, KeyRound, Monitor, Receipt, Sparkles, User } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useMemo } from "react";
|
||||
import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent";
|
||||
import { CommunityPromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent";
|
||||
import { ProfileContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent";
|
||||
|
|
@ -11,37 +12,42 @@ import { PurchaseHistoryContent } from "@/app/dashboard/[search_space_id]/user-s
|
|||
import { DesktopContent } from "@/app/dashboard/[search_space_id]/user-settings/components/DesktopContent";
|
||||
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||
import { SettingsDialog } from "@/components/settings/settings-dialog";
|
||||
import { usePlatform } from "@/hooks/use-platform";
|
||||
|
||||
export function UserSettingsDialog() {
|
||||
const t = useTranslations("userSettings");
|
||||
const [state, setState] = useAtom(userSettingsDialogAtom);
|
||||
const { isDesktop } = usePlatform();
|
||||
|
||||
const navItems = [
|
||||
{ value: "profile", label: t("profile_nav_label"), icon: <User className="h-4 w-4" /> },
|
||||
{
|
||||
value: "api-key",
|
||||
label: t("api_key_nav_label"),
|
||||
icon: <KeyRound className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "prompts",
|
||||
label: "My Prompts",
|
||||
icon: <Sparkles className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "community-prompts",
|
||||
label: "Community Prompts",
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "purchases",
|
||||
label: "Purchase History",
|
||||
icon: <Receipt className="h-4 w-4" />,
|
||||
},
|
||||
...(typeof window !== "undefined" && window.electronAPI
|
||||
? [{ value: "desktop", label: "Desktop", icon: <Monitor className="h-4 w-4" /> }]
|
||||
: []),
|
||||
];
|
||||
const navItems = useMemo(
|
||||
() => [
|
||||
{ value: "profile", label: t("profile_nav_label"), icon: <User className="h-4 w-4" /> },
|
||||
{
|
||||
value: "api-key",
|
||||
label: t("api_key_nav_label"),
|
||||
icon: <KeyRound className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "prompts",
|
||||
label: "My Prompts",
|
||||
icon: <Sparkles className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "community-prompts",
|
||||
label: "Community Prompts",
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "purchases",
|
||||
label: "Purchase History",
|
||||
icon: <Receipt className="h-4 w-4" />,
|
||||
},
|
||||
...(isDesktop
|
||||
? [{ value: "desktop", label: "Desktop", icon: <Monitor className="h-4 w-4" /> }]
|
||||
: []),
|
||||
],
|
||||
[t, isDesktop]
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsDialog
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { Progress } from "@/components/ui/progress";
|
|||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import {
|
||||
trackDocumentUploadFailure,
|
||||
trackDocumentUploadStarted,
|
||||
|
|
@ -151,10 +152,11 @@ export function DocumentUploadTab({
|
|||
};
|
||||
}, []);
|
||||
|
||||
const electronAPI = useElectronAPI();
|
||||
const [selectedFolder, setSelectedFolder] = useState<SelectedFolder | null>(null);
|
||||
const [watchFolder, setWatchFolder] = useState(true);
|
||||
const [folderSubmitting, setFolderSubmitting] = useState(false);
|
||||
const isElectron = typeof window !== "undefined" && !!window.electronAPI?.browseFiles;
|
||||
const isElectron = !!electronAPI?.browseFiles;
|
||||
|
||||
const acceptedFileTypes = useMemo(() => {
|
||||
const etlService = process.env.NEXT_PUBLIC_ETL_SERVICE;
|
||||
|
|
@ -216,33 +218,31 @@ export function DocumentUploadTab({
|
|||
}, []);
|
||||
|
||||
const handleBrowseFiles = useCallback(async () => {
|
||||
const api = window.electronAPI;
|
||||
if (!api?.browseFiles) return;
|
||||
if (!electronAPI?.browseFiles) return;
|
||||
|
||||
const paths = await api.browseFiles();
|
||||
const paths = await electronAPI.browseFiles();
|
||||
if (!paths || paths.length === 0) return;
|
||||
|
||||
setSelectedFolder(null);
|
||||
const fileDataList = await api.readLocalFiles(paths);
|
||||
const fileDataList = await electronAPI.readLocalFiles(paths);
|
||||
const newFiles: FileWithId[] = fileDataList.map((fd) => ({
|
||||
id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`,
|
||||
file: new File([fd.data], fd.name, { type: fd.mimeType }),
|
||||
}));
|
||||
setFiles((prev) => [...prev, ...newFiles]);
|
||||
}, []);
|
||||
}, [electronAPI]);
|
||||
|
||||
const handleBrowseFolder = useCallback(async () => {
|
||||
const api = window.electronAPI;
|
||||
if (!api?.selectFolder) return;
|
||||
if (!electronAPI?.selectFolder) return;
|
||||
|
||||
const folderPath = await api.selectFolder();
|
||||
const folderPath = await electronAPI.selectFolder();
|
||||
if (!folderPath) return;
|
||||
|
||||
const folderName = folderPath.split("/").pop() || folderPath.split("\\").pop() || folderPath;
|
||||
setFiles([]);
|
||||
setSelectedFolder({ path: folderPath, name: folderName });
|
||||
setWatchFolder(true);
|
||||
}, []);
|
||||
}, [electronAPI]);
|
||||
|
||||
const handleFolderChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
|
|
@ -287,9 +287,7 @@ export function DocumentUploadTab({
|
|||
);
|
||||
|
||||
const handleFolderSubmit = useCallback(async () => {
|
||||
if (!selectedFolder) return;
|
||||
const api = window.electronAPI;
|
||||
if (!api) return;
|
||||
if (!selectedFolder || !electronAPI) return;
|
||||
|
||||
setFolderSubmitting(true);
|
||||
try {
|
||||
|
|
@ -304,7 +302,7 @@ export function DocumentUploadTab({
|
|||
const rootFolderId = (result as { root_folder_id?: number })?.root_folder_id ?? null;
|
||||
|
||||
if (watchFolder) {
|
||||
await api.addWatchedFolder({
|
||||
await electronAPI.addWatchedFolder({
|
||||
path: selectedFolder.path,
|
||||
name: selectedFolder.name,
|
||||
excludePatterns: [
|
||||
|
|
@ -332,7 +330,7 @@ export function DocumentUploadTab({
|
|||
} finally {
|
||||
setFolderSubmitting(false);
|
||||
}
|
||||
}, [selectedFolder, watchFolder, searchSpaceId, shouldSummarize, onSuccess]);
|
||||
}, [selectedFolder, watchFolder, searchSpaceId, shouldSummarize, onSuccess, electronAPI]);
|
||||
|
||||
const handleUpload = async () => {
|
||||
setUploadProgress(0);
|
||||
|
|
|
|||
31
surfsense_web/contexts/platform-context.tsx
Normal file
31
surfsense_web/contexts/platform-context.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
export interface PlatformContextValue {
|
||||
isDesktop: boolean;
|
||||
isWeb: boolean;
|
||||
electronAPI: ElectronAPI | null;
|
||||
}
|
||||
|
||||
const SSR_VALUE: PlatformContextValue = {
|
||||
isDesktop: false,
|
||||
isWeb: false,
|
||||
electronAPI: null,
|
||||
};
|
||||
|
||||
export const PlatformContext = createContext<PlatformContextValue>(SSR_VALUE);
|
||||
|
||||
export function PlatformProvider({ children }: { children: ReactNode }) {
|
||||
const [value, setValue] = useState<PlatformContextValue>(SSR_VALUE);
|
||||
|
||||
useEffect(() => {
|
||||
const api = window.electronAPI ?? null;
|
||||
const isDesktop = !!api;
|
||||
setValue({ isDesktop, isWeb: !isDesktop, electronAPI: api });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PlatformContext.Provider value={value}>{children}</PlatformContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
|
||||
interface FileChangedEvent {
|
||||
|
|
@ -29,6 +30,7 @@ interface BatchItem {
|
|||
}
|
||||
|
||||
export function useFolderSync() {
|
||||
const electronAPI = useElectronAPI();
|
||||
const queueRef = useRef<BatchItem[]>([]);
|
||||
const processingRef = useRef(false);
|
||||
const debounceTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
|
@ -49,9 +51,8 @@ export function useFolderSync() {
|
|||
target_file_paths: batch.filePaths,
|
||||
root_folder_id: batch.rootFolderId,
|
||||
});
|
||||
const api = typeof window !== "undefined" ? window.electronAPI : null;
|
||||
if (api?.acknowledgeFileEvents && batch.ackIds.length > 0) {
|
||||
await api.acknowledgeFileEvents(batch.ackIds);
|
||||
if (electronAPI?.acknowledgeFileEvents && batch.ackIds.length > 0) {
|
||||
await electronAPI.acknowledgeFileEvents(batch.ackIds);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[FolderSync] Failed to trigger batch re-index:", err);
|
||||
|
|
@ -117,25 +118,22 @@ export function useFolderSync() {
|
|||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
const api = typeof window !== "undefined" ? window.electronAPI : null;
|
||||
if (!api?.onFileChanged) {
|
||||
if (!electronAPI?.onFileChanged) {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}
|
||||
|
||||
// Signal to main process that the renderer is ready to receive events
|
||||
api.signalRendererReady?.();
|
||||
electronAPI.signalRendererReady?.();
|
||||
|
||||
// Drain durable outbox first so events survive renderer startup gaps and restarts
|
||||
void api.getPendingFileEvents?.().then((pendingEvents) => {
|
||||
void electronAPI.getPendingFileEvents?.().then((pendingEvents) => {
|
||||
if (!isMountedRef.current || !pendingEvents?.length) return;
|
||||
for (const event of pendingEvents) {
|
||||
enqueueWithDebounce(event);
|
||||
}
|
||||
});
|
||||
|
||||
const cleanup = api.onFileChanged((event: FileChangedEvent) => {
|
||||
const cleanup = electronAPI.onFileChanged((event: FileChangedEvent) => {
|
||||
enqueueWithDebounce(event);
|
||||
});
|
||||
|
||||
|
|
@ -149,5 +147,5 @@ export function useFolderSync() {
|
|||
pendingByFolder.current.clear();
|
||||
firstEventTime.current.clear();
|
||||
};
|
||||
}, []);
|
||||
}, [electronAPI]);
|
||||
}
|
||||
|
|
|
|||
12
surfsense_web/hooks/use-platform.ts
Normal file
12
surfsense_web/hooks/use-platform.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { useContext } from "react";
|
||||
import { PlatformContext, type PlatformContextValue } from "@/contexts/platform-context";
|
||||
|
||||
export function usePlatform(): Pick<PlatformContextValue, "isDesktop" | "isWeb"> {
|
||||
const { isDesktop, isWeb } = useContext(PlatformContext);
|
||||
return { isDesktop, isWeb };
|
||||
}
|
||||
|
||||
export function useElectronAPI(): ElectronAPI | null {
|
||||
const { electronAPI } = useContext(PlatformContext);
|
||||
return electronAPI;
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ const PUBLIC_ROUTE_PREFIXES = [
|
|||
"/login",
|
||||
"/register",
|
||||
"/auth",
|
||||
"/desktop/login",
|
||||
"/docs",
|
||||
"/public",
|
||||
"/invite",
|
||||
|
|
@ -34,6 +35,11 @@ export function isPublicRoute(pathname: string): boolean {
|
|||
return PUBLIC_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function getLoginPath(): string {
|
||||
if (typeof window !== "undefined" && window.electronAPI) return "/desktop/login";
|
||||
return "/login";
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears tokens and optionally redirects to login.
|
||||
* Call this when a 401 response is received.
|
||||
|
|
@ -55,7 +61,7 @@ export function handleUnauthorized(): void {
|
|||
if (!excludedPaths.includes(pathname)) {
|
||||
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
||||
}
|
||||
window.location.href = "/login";
|
||||
window.location.href = getLoginPath();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -221,13 +227,12 @@ export function redirectToLogin(): void {
|
|||
const currentPath = window.location.pathname + window.location.search + window.location.hash;
|
||||
|
||||
// Don't save auth-related paths or home page
|
||||
const excludedPaths = ["/auth", "/auth/callback", "/", "/login", "/register"];
|
||||
const excludedPaths = ["/auth", "/auth/callback", "/", "/login", "/register", "/desktop/login"];
|
||||
if (!excludedPaths.includes(window.location.pathname)) {
|
||||
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
||||
}
|
||||
|
||||
// Redirect to login page
|
||||
window.location.href = "/login";
|
||||
window.location.href = getLoginPath();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
3
surfsense_web/types/window.d.ts
vendored
3
surfsense_web/types/window.d.ts
vendored
|
|
@ -81,6 +81,9 @@ interface ElectronAPI {
|
|||
// Auth token sync across windows
|
||||
getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>;
|
||||
setAuthTokens: (bearer: string, refresh: string) => Promise<void>;
|
||||
// Keyboard shortcut configuration
|
||||
getShortcuts: () => Promise<{ quickAsk: string; autocomplete: string }>;
|
||||
setShortcuts: (config: Partial<{ quickAsk: string; autocomplete: string }>) => Promise<{ quickAsk: string; autocomplete: string }>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue