chore: up

This commit is contained in:
rain9
2025-12-31 21:54:36 +08:00
parent 59da62e73c
commit 42ab7b51dd
20 changed files with 337 additions and 215 deletions

View File

@@ -15,6 +15,7 @@ Information about release notes of Coco App is provided here.
- feat: support app search even if Spotlight is disabled #1028
- feat: read localized names from root InfoPlist.strings #1029
- feat: view extension supports detachable #1042
### 🐛 Bug fix
@@ -23,6 +24,7 @@ Information about release notes of Coco App is provided here.
- fix: fix arrow keys not working after closing the context menu #1035
- fix: fix some filter fields not working #1037
- fix: apply local results weight to scores generated by rerank() #1036
- fix: prevent window collapse when chat history is open #1039
### ✈️ Improvements

View File

@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main", "chat", "settings", "check", "selection", "view_extension"],
"windows": ["main", "chat", "settings", "check", "selection", "view_extension", "view_extension_*"],
"permissions": [
"core:default",
"core:event:allow-emit",

View File

@@ -399,11 +399,17 @@ async fn show_settings(app_handle: AppHandle) {
}
#[tauri::command]
async fn show_view_extension(app_handle: AppHandle) {
async fn show_view_extension(
app_handle: AppHandle,
label: Option<String>,
query: Option<String>,
width: Option<f64>,
height: Option<f64>,
) {
log::debug!("view extension menu item was clicked");
let label = VIEW_EXTENSION_WINDOW_LABEL;
let window_label = label.unwrap_or_else(|| VIEW_EXTENSION_WINDOW_LABEL.to_string());
if let Some(window) = app_handle.get_webview_window(label) {
if let Some(window) = app_handle.get_webview_window(&window_label) {
window.show().unwrap();
window.unminimize().unwrap();
window.set_focus().unwrap();
@@ -411,13 +417,18 @@ async fn show_view_extension(app_handle: AppHandle) {
}
// If window doesn't exist (e.g. was closed), create it
let url = WebviewUrl::App("/ui/view-extension".into());
let build_result = WebviewWindowBuilder::new(&app_handle, label, url)
let url_suffix = query.unwrap_or_else(|| "".to_string());
let url = WebviewUrl::App(format!("/ui/view-extension{}", url_suffix).into());
let w = width.unwrap_or(1000.0);
let h = height.unwrap_or(800.0);
let build_result = WebviewWindowBuilder::new(&app_handle, &window_label, url)
.title("View Extension")
.inner_size(1000.0, 800.0)
.inner_size(w, h)
.min_inner_size(800.0, 600.0)
.resizable(true)
.visible(true)
.center()
.visible(false)
.build();
match build_result {

View File

@@ -22,9 +22,8 @@ import {
canNavigateBack,
getUploadedAttachmentsId,
isDefaultServer,
visibleFilterBar,
visibleSearchBar,
} from "@/utils";
import { useVisibleSearchBar, useVisibleFilterBar } from "@/hooks/useViewExtensionUI";
import { useTauriFocus } from "@/hooks/useTauriFocus";
import { SendMessageParams } from "../Assistant/Chat";
import { isEmpty } from "lodash-es";
@@ -92,6 +91,8 @@ export default function ChatInput({
getFileIcon,
}: ChatInputProps) {
const { t } = useTranslation();
const isVisibleSearchBar = useVisibleSearchBar();
const isVisibleFilterBar = useVisibleFilterBar();
const { currentAssistant } = useConnectStore();
@@ -311,7 +312,7 @@ export default function ChatInput({
className={`flex items-center dark:text-[#D8D8D8] rounded-md transition-all relative overflow-hidden`}
>
{lineCount === 1 && renderSearchIcon()}
{visibleSearchBar() && (
{isVisibleSearchBar && (
<div
className={clsx(
"min-h-10 w-full p-[7px] bg-[#ededed] dark:bg-[#202126]",
@@ -335,7 +336,7 @@ export default function ChatInput({
)}
</div>
{visibleFilterBar() && (
{isVisibleFilterBar && (
<InputControls
isChatMode={isChatMode}
isChatPage={isChatPage}

View File

@@ -13,7 +13,7 @@ import { useSearch } from "@/hooks/useSearch";
import ExtensionStore from "./ExtensionStore";
import platformAdapter from "@/utils/platformAdapter";
import ViewExtension from "./ViewExtension";
import { visibleFooterBar } from "@/utils";
import { useVisibleFooterBar } from "@/hooks/useViewExtensionUI";
const SearchResultsPanel = memo<{
input: string;
@@ -55,7 +55,9 @@ const SearchResultsPanel = memo<{
selectedSearchContent,
visibleExtensionStore
} = useSearchStore();
const viewExtensionOpened = useExtensionStore((state) => state.viewExtensionOpened);
const viewExtensionOpened = useExtensionStore((state) =>
state.viewExtensions.length > 0 ? state.viewExtensions[state.viewExtensions.length - 1] : undefined
);
useEffect(() => {
if (selectedSearchContent?.type === "AI Assistant") {
@@ -174,12 +176,13 @@ function Search({
formatUrl,
}: SearchProps) {
const mainWindowRef = useRef<HTMLDivElement>(null);
const isVisibleFooterBar = useVisibleFooterBar();
return (
<div
ref={mainWindowRef}
className={clsx("h-full w-full relative", {
"pb-8": visibleFooterBar(),
"pb-8": isVisibleFooterBar,
})}
>
<SearchResultsPanel
@@ -190,7 +193,7 @@ function Search({
formatUrl={formatUrl}
/>
{visibleFooterBar() && <Footer setIsPinnedWeb={setIsPinned} />}
{isVisibleFooterBar && <Footer setIsPinnedWeb={setIsPinned} />}
<ContextMenu formatUrl={formatUrl} />
</div>

View File

@@ -9,7 +9,8 @@ import { useThemeStore } from "@/stores/themeStore";
import { useSearchStore } from "@/stores/searchStore";
import { useExtensionStore } from "@/stores/extensionStore";
import platformAdapter from "@/utils/platformAdapter";
import { navigateBack, visibleSearchBar } from "@/utils";
import { navigateBack } from "@/utils";
import { useVisibleSearchBar } from "@/hooks/useViewExtensionUI";
import VisibleKey from "../Common/VisibleKey";
import { cn } from "@/lib/utils";
@@ -21,6 +22,7 @@ interface MultilevelWrapperProps {
const MultilevelWrapper: FC<MultilevelWrapperProps> = (props) => {
const { icon, title = "" } = props;
const { isDark } = useThemeStore();
const isVisibleSearchBar = useVisibleSearchBar();
const renderIcon = () => {
if (!icon) {
@@ -40,8 +42,8 @@ const MultilevelWrapper: FC<MultilevelWrapperProps> = (props) => {
className={clsx(
"flex items-center h-10 gap-1 px-2 border border-(--border) rounded-l-lg",
{
"justify-center": visibleSearchBar(),
"w-[calc(100vw-16px)] rounded-r-lg": !visibleSearchBar(),
"justify-center": isVisibleSearchBar,
"w-[calc(100vw-16px)] rounded-r-lg": !isVisibleSearchBar,
}
)}
>
@@ -78,7 +80,9 @@ export default function SearchIcons({
selectedExtension
} = useSearchStore();
const viewExtensionOpened = useExtensionStore((state) => state.viewExtensionOpened);
const viewExtensionOpened = useExtensionStore((state) =>
state.viewExtensions.length > 0 ? state.viewExtensions[state.viewExtensions.length - 1] : undefined
);
if (isChatMode) {
return null;

View File

@@ -3,27 +3,34 @@ import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Focus, ExternalLink } from "lucide-react";
import { useExtensionStore } from "@/stores/extensionStore";
import { useExtensionStore, type ViewExtensionOpened } from "@/stores/extensionStore";
import platformAdapter from "@/utils/platformAdapter";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { useViewExtensionWindow } from "@/hooks/useViewExtensionWindow";
import ViewExtensionIframe from "./ViewExtensionIframe";
import { apiPermissionCheck, fsPermissionCheck } from "./viewExtensionPermissions";
import {
apiPermissionCheck,
fsPermissionCheck,
} from "./viewExtensionPermissions";
type ControlsProps = {
showFullscreen?: boolean;
showDetach?: boolean;
showFocus?: boolean;
forceResizable?: boolean;
initialViewExtensionOpened?: ViewExtensionOpened | null;
};
const ViewExtensionContent: React.FC<ControlsProps> = ({
showFullscreen = true,
showDetach = true,
showFocus = true,
forceResizable = false,
initialViewExtensionOpened = null,
}) => {
const viewExtensionOpened = useExtensionStore((state) => state.viewExtensionOpened);
const storeView = useExtensionStore((state) =>
state.viewExtensions.length > 0 ? state.viewExtensions[state.viewExtensions.length - 1] : undefined
);
const viewExtensionOpened = initialViewExtensionOpened ?? storeView;
// Complete list of the backend APIs, grouped by their category.
const [apis, setApis] = useState<Map<string, string[]> | null>(null);
@@ -177,45 +184,70 @@ const ViewExtensionContent: React.FC<ControlsProps> = ({
detachable,
hideScrollbar,
scale,
baseWidth,
baseHeight,
iframeRef,
focusIframe,
setBaseSize,
} = useViewExtensionWindow({ forceResizable });
const [iframeReady, setIframeReady] = useState(false);
return (
<div className="relative w-full h-full">
<div className="absolute top-2 right-2 z-10 flex items-center gap-2">
{resizable && showFocus && (
<button
aria-label={t("viewExtension.focus")}
className="rounded-md bg-black/40 text-white p-2 hover:bg-black/60 focus:outline-none"
onClick={focusIframe}
>
<Focus className="size-4" />
</button>
)}
{((detachable && showDetach) || (resizable && showFullscreen)) && (
<button
aria-label={t("viewExtension.detach")}
className="rounded-md bg-black/40 text-white p-2 hover:bg-black/60 focus:outline-none"
onClick={() => platformAdapter.invokeBackend("show_view_extension")}
>
<ExternalLink className="size-4" />
</button>
)}
</div>
<div className="relative w-full h-full overflow-hidden">
{iframeReady && (
<div className="absolute top-2 right-2 z-10 flex items-center gap-2">
{resizable && showFocus && (
<button
aria-label={t("viewExtension.focus")}
className="rounded-md bg-black/40 text-white p-2 hover:bg-black/60 focus:outline-none"
onClick={focusIframe}
>
<Focus className="size-4" />
</button>
)}
{detachable && showDetach && (
<button
aria-label={t("viewExtension.detach")}
className="rounded-md bg-black/40 text-white p-2 hover:bg-black/60 focus:outline-none"
onClick={() => {
const ext = viewExtensionOpened!;
const name = ext[0] || "extension";
const safe = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
const label = `view_extension_${safe}_${Date.now()}`;
const payload = btoa(encodeURIComponent(JSON.stringify(ext)));
platformAdapter.invokeBackend("show_view_extension", {
label,
query: `?ext=${payload}`,
width: baseWidth,
height: baseHeight,
});
}}
>
<ExternalLink className="size-4" />
</button>
)}
</div>
)}
<ViewExtensionIframe
fileUrl={fileUrl}
scale={scale}
baseWidth={baseWidth}
baseHeight={baseHeight}
iframeRef={iframeRef}
hideScrollbar={hideScrollbar}
focusIframe={focusIframe}
onLoaded={(ok) => setIframeReady(ok)}
onContentSize={(size) => setBaseSize(size.width, size.height)}
/>
</div>
);
};
const ViewExtension: React.FC<ControlsProps> = (props) => {
const viewExtensionOpened = useExtensionStore((state) => state.viewExtensionOpened);
const viewExtensionOpened = useExtensionStore(
(state) => state.viewExtensions.length > 0 ? state.viewExtensions[state.viewExtensions.length - 1] : undefined
);
if (viewExtensionOpened == null) {
return (

View File

@@ -44,10 +44,24 @@ type ViewExtensionIframeProps = {
iframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
hideScrollbar: boolean;
focusIframe: () => void;
onLoaded?: (success: boolean) => void;
onContentSize?: (size: { width: number; height: number }) => void;
baseWidth?: number;
baseHeight?: number;
};
export default function ViewExtensionIframe(props: ViewExtensionIframeProps) {
const { fileUrl, scale, iframeRef, hideScrollbar, focusIframe } = props;
const {
fileUrl,
scale,
iframeRef,
hideScrollbar,
focusIframe,
onLoaded,
onContentSize,
baseWidth,
baseHeight,
} = props;
const isSameOrigin = () => {
try {
@@ -84,6 +98,8 @@ export default function ViewExtensionIframe(props: ViewExtensionIframeProps) {
className="border-0 w-full h-full"
scrolling={hideScrollbar ? "no" : "auto"}
style={{
width: baseWidth ? `${baseWidth}px` : undefined,
height: baseHeight ? `${baseHeight}px` : undefined,
transform: `scale(${scale})`,
transformOrigin: "center center",
outline: "none",
@@ -98,7 +114,31 @@ export default function ViewExtensionIframe(props: ViewExtensionIframeProps) {
iframeRef.current?.contentWindow?.focus();
} catch {}
applyHideScrollbarToIframe(event.currentTarget, hideScrollbar);
try {
const doc = event.currentTarget.contentDocument!;
const root = doc.documentElement;
const body = doc.body;
const width = Math.max(
root?.scrollWidth ?? 0,
body?.scrollWidth ?? 0,
root?.clientWidth ?? 0,
body?.clientWidth ?? 0
);
const height = Math.max(
root?.scrollHeight ?? 0,
body?.scrollHeight ?? 0,
root?.clientHeight ?? 0,
body?.clientHeight ?? 0
);
if (width > 0 && height > 0) {
onContentSize?.({ width, height });
}
} catch {}
}
onLoaded?.(true);
}}
onError={() => {
onLoaded?.(false);
}}
/>
</div>

View File

@@ -31,9 +31,8 @@ import type { StartPage } from "@/types/chat";
import {
canNavigateBack,
hasUploadingAttachment,
visibleFilterBar,
visibleSearchBar,
} from "@/utils";
import { useVisibleSearchBar, useVisibleFilterBar } from "@/hooks/useViewExtensionUI";
import { useTauriFocus } from "@/hooks/useTauriFocus";
import {
POPOVER_PANEL_SELECTOR,
@@ -79,6 +78,8 @@ function SearchChat({
formatUrl,
}: SearchChatProps) {
const currentAssistant = useConnectStore((state) => state.currentAssistant);
const isVisibleSearchBar = useVisibleSearchBar();
const isVisibleFilterBar = useVisibleFilterBar();
const source = currentAssistant?._source;
@@ -121,7 +122,8 @@ function SearchChat({
});
const setWindowSize = useCallback(() => {
const { viewExtensionOpened } = useExtensionStore.getState();
const { viewExtensions } = useExtensionStore.getState();
const viewExtensionOpened = viewExtensions.length > 0 ? viewExtensions[viewExtensions.length - 1] : undefined;
if (collapseWindowTimer.current) {
clearTimeout(collapseWindowTimer.current);
@@ -480,7 +482,7 @@ function SearchChat({
className={clsx(
"relative p-2 w-full flex justify-center transition-all duration-500",
{
"min-h-[84px]": visibleSearchBar() && visibleFilterBar(),
"min-h-[84px]": isVisibleSearchBar && isVisibleFilterBar,
}
)}
>

View File

@@ -11,7 +11,8 @@ export const useCanNavigateBack = () => {
sourceData,
} = useSearchStore();
const { viewExtensionOpened } = useExtensionStore.getState();
const { viewExtensions } = useExtensionStore.getState();
const viewExtensionOpened = viewExtensions.length > 0 ? viewExtensions[viewExtensions.length - 1] : undefined;
const canNavigateBack = useMemo(() => {
return (

View File

@@ -8,7 +8,9 @@ import { closeHistoryPanel } from "@/utils";
const useEscape = () => {
const { setVisibleContextMenu } = useSearchStore();
const viewExtensionOpened = useExtensionStore((state) => state.viewExtensionOpened);
const viewExtensionOpened = useExtensionStore((state) =>
state.viewExtensions.length > 0 ? state.viewExtensions[state.viewExtensions.length - 1] : undefined
);
useKeyPress("esc", (event) => {
event.preventDefault();

View File

@@ -0,0 +1,46 @@
import { useSearchStore } from "@/stores/searchStore";
import { useExtensionStore } from "@/stores/extensionStore";
import { isNil } from "lodash-es";
export const useVisibleSearchBar = () => {
const visibleExtensionDetail = useSearchStore((state) => state.visibleExtensionDetail);
const viewExtensions = useExtensionStore((state) => state.viewExtensions);
const viewExtensionOpened = viewExtensions.length > 0 ? viewExtensions[viewExtensions.length - 1] : undefined;
if (visibleExtensionDetail) return false;
if (isNil(viewExtensionOpened)) return true;
const ui = viewExtensionOpened[4];
return ui?.search_bar ?? false;
};
export const useVisibleFilterBar = () => {
const visibleExtensionStore = useSearchStore((state) => state.visibleExtensionStore);
const visibleExtensionDetail = useSearchStore((state) => state.visibleExtensionDetail);
const goAskAi = useSearchStore((state) => state.goAskAi);
const viewExtensions = useExtensionStore((state) => state.viewExtensions);
const viewExtensionOpened = viewExtensions.length > 0 ? viewExtensions[viewExtensions.length - 1] : undefined;
if (visibleExtensionStore || visibleExtensionDetail || goAskAi) return false;
if (isNil(viewExtensionOpened)) return true;
const ui = viewExtensionOpened[4];
return ui?.filter_bar ?? false;
};
export const useVisibleFooterBar = () => {
const viewExtensions = useExtensionStore((state) => state.viewExtensions);
const viewExtensionOpened = viewExtensions.length > 0 ? viewExtensions[viewExtensions.length - 1] : undefined;
if (isNil(viewExtensionOpened)) return true;
const ui = viewExtensionOpened[4];
return ui?.footer ?? false;
};

View File

@@ -1,10 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import platformAdapter from "@/utils/platformAdapter";
import { isMac } from "@/utils/platform";
import type { ViewExtensionUISettingsOrNull } from "@/components/Settings/Extensions";
import { useAppStore } from "@/stores/appStore";
import { useExtensionStore } from "@/stores/extensionStore";
import { useExtensionStore, type ViewExtensionOpened } from "@/stores/extensionStore";
type WindowSnapshot = {
width: number;
@@ -14,11 +13,18 @@ type WindowSnapshot = {
y: number;
};
export function useViewExtensionWindow(opts?: { forceResizable?: boolean }) {
const isTauri = useAppStore((state) => state.isTauri);
const viewExtensionOpened = useExtensionStore((state) => state.viewExtensionOpened);
if (viewExtensionOpened == null) {
export function useViewExtensionWindow(opts?: {
forceResizable?: boolean;
ignoreExplicitSize?: boolean;
viewExtension?: ViewExtensionOpened | null;
}) {
const isTauri = useAppStore((state) => state.isTauri);
const storeViewExtension = useExtensionStore((state) =>
state.viewExtensions.length > 0 ? state.viewExtensions[state.viewExtensions.length - 1] : undefined
);
const viewExtensionOpened = opts?.viewExtension ?? storeViewExtension;
if (viewExtensionOpened == null) {
throw new Error(
"ViewExtension Error: viewExtensionOpened is null. This should not happen."
);
@@ -33,12 +39,11 @@ export function useViewExtensionWindow(opts?: { forceResizable?: boolean }) {
const uiWidth = ui && typeof ui.width === "number" ? ui.width : null;
const uiHeight = ui && typeof ui.height === "number" ? ui.height : null;
const hasExplicitWindowSize = uiWidth != null && uiHeight != null;
const hasExplicitWindowSize =
uiWidth != null && uiHeight != null && !opts?.ignoreExplicitSize;
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const prevWindowRef = useRef<WindowSnapshot | null>(null);
const fullscreenPrevRef = useRef<WindowSnapshot | null>(null);
const [scale, setScale] = useState(1);
const [fallbackViewSize, setFallbackViewSize] = useState<{
width: number;
@@ -78,93 +83,13 @@ export function useViewExtensionWindow(opts?: { forceResizable?: boolean }) {
return;
}
const size = await platformAdapter.getWindowSize();
const nextScale = Math.min(size.width / baseWidth, size.height / baseHeight);
const ratioW = size.width / baseWidth;
const ratioH = size.height / baseHeight;
const nextScale = Math.min(ratioW, ratioH);
setScale(Math.max(nextScale, 0.1));
}, [baseHeight, baseWidth, hasExplicitWindowSize]);
const applyFullscreen = useCallback(
async (next: boolean, options?: { centerOnExit?: boolean }) => {
const centerOnExit = options?.centerOnExit ?? true;
if (next) {
const size = await platformAdapter.getWindowSize();
const resizable = await platformAdapter.isWindowResizable();
const pos = await platformAdapter.getWindowPosition();
fullscreenPrevRef.current = {
width: size.width,
height: size.height,
resizable,
x: pos.x,
y: pos.y,
};
if (isMac && isTauri) {
const monitor = await platformAdapter.getMonitorFromCursor();
if (!monitor) return;
const window = await platformAdapter.getCurrentWebviewWindow();
const factor = await window.scaleFactor();
const { size, position } = monitor;
const { width, height } = size.toLogical(factor);
const { x, y } = position.toLogical(factor);
await platformAdapter.setWindowSize(width, height);
await platformAdapter.setWindowPosition(x, y);
await platformAdapter.setWindowResizable(true);
await recomputeScale();
} else {
await platformAdapter.setWindowFullscreen(true);
await recomputeScale();
}
} else {
const prevPos =
fullscreenPrevRef.current != null
? { x: fullscreenPrevRef.current.x, y: fullscreenPrevRef.current.y }
: null;
if (!isMac) {
await platformAdapter.setWindowFullscreen(false);
}
if (fullscreenPrevRef.current) {
const prev = fullscreenPrevRef.current;
await platformAdapter.setWindowSize(prev.width, prev.height);
await platformAdapter.setWindowResizable(prev.resizable);
fullscreenPrevRef.current = null;
} else if (hasExplicitWindowSize) {
const nextResizable =
ui && typeof ui.resizable === "boolean" ? ui.resizable : true;
await platformAdapter.setWindowSize(uiWidth, uiHeight);
await platformAdapter.setWindowResizable(nextResizable);
}
if (centerOnExit) {
await platformAdapter.centerOnCurrentMonitor();
} else if (prevPos != null) {
await platformAdapter.setWindowPosition(prevPos.x, prevPos.y);
}
await recomputeScale();
focusIframeSoon();
}
},
[
focusIframeSoon,
hasExplicitWindowSize,
isTauri,
recomputeScale,
ui,
uiHeight,
uiWidth,
]
);
const toggleFullscreen = useCallback(async () => {
const next = !isFullscreen;
await applyFullscreen(next);
setIsFullscreen(next);
if (next) focusIframe();
}, [applyFullscreen, focusIframe, isFullscreen]);
useEffect(() => {
const applyWindowSettings = async () => {
@@ -180,6 +105,10 @@ export function useViewExtensionWindow(opts?: { forceResizable?: boolean }) {
y: pos.y,
};
if (isTauri && (await platformAdapter.isWindowFullscreen())) {
return;
}
if (hasExplicitWindowSize) {
const nextResizable =
opts?.forceResizable
@@ -194,6 +123,13 @@ export function useViewExtensionWindow(opts?: { forceResizable?: boolean }) {
} else {
await recomputeScale();
}
// If we are in the standalone view extension window (not the preview in main window),
// we need to show the window explicitly because it is created hidden to avoid flickering.
if (window.location.pathname.includes("/ui/view-extension")) {
await platformAdapter.showWindow();
}
focusIframeSoon();
};
@@ -201,15 +137,11 @@ export function useViewExtensionWindow(opts?: { forceResizable?: boolean }) {
return () => {
if (prevWindowRef.current) {
const prev = prevWindowRef.current;
if (!isMac && fullscreenPrevRef.current != null) {
platformAdapter.setWindowFullscreen(false);
}
platformAdapter.setWindowSize(prev.width, prev.height);
platformAdapter.setWindowResizable(prev.resizable);
platformAdapter.setWindowPosition(prev.x, prev.y);
prevWindowRef.current = null;
fullscreenPrevRef.current = null;
}
};
}, [
@@ -222,19 +154,16 @@ export function useViewExtensionWindow(opts?: { forceResizable?: boolean }) {
]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isFullscreen) {
applyFullscreen(false);
setIsFullscreen(false);
const handleResize = async () => {
if (hasExplicitWindowSize) {
recomputeScale();
}
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("keydown", handleKeyDown, {
capture: true,
} as any);
window.removeEventListener("resize", handleResize);
};
}, [applyFullscreen, isFullscreen]);
}, [hasExplicitWindowSize, recomputeScale]);
return {
ui,
@@ -242,9 +171,15 @@ export function useViewExtensionWindow(opts?: { forceResizable?: boolean }) {
detachable,
hideScrollbar,
scale,
baseWidth,
baseHeight,
iframeRef,
isFullscreen,
toggleFullscreen,
focusIframe,
setBaseSize: (width: number, height: number) => {
if (!hasExplicitWindowSize) {
setFallbackViewSize({ width, height });
recomputeScale();
}
},
};
}

View File

@@ -12,7 +12,7 @@ import platformAdapter from "@/utils/platformAdapter";
function MainApp() {
const { setIsTauri } = useAppStore();
const setViewExtensionOpened = useExtensionStore((state) => state.setViewExtensionOpened);
const addViewExtension = useExtensionStore((state) => state.addViewExtension);
useEffect(() => {
setIsTauri(true);
@@ -24,7 +24,7 @@ function MainApp() {
platformAdapter.listenEvent("open_view_extension", async ({ payload }) => {
await platformAdapter.showWindow();
setViewExtensionOpened(payload);
addViewExtension(payload);
});
}, []);

View File

@@ -1,11 +1,27 @@
import React from "react";
import ViewExtension from "@/components/Search/ViewExtension";
import type { ViewExtensionOpened } from "@/stores/extensionStore";
const ViewExtensionPage: React.FC = () => {
const params = new URLSearchParams(window.location.search);
const encoded = params.get("ext");
let initial: ViewExtensionOpened | null = null;
if (encoded) {
try {
const json = decodeURIComponent(atob(encoded));
initial = JSON.parse(json) as ViewExtensionOpened;
} catch {}
}
return (
<div className="w-screen h-screen bg-background text-foreground overflow-hidden">
<ViewExtension showFullscreen={false} showDetach={false} showFocus forceResizable />
<ViewExtension
showFullscreen={false}
showDetach={false}
showFocus
forceResizable
initialViewExtensionOpened={initial}
/>
</div>
);
};

View File

@@ -18,15 +18,68 @@ export type ViewExtensionOpened = [
];
type ExtensionStore = {
// When we open a View extension, we set this to a non-null value.
viewExtensionOpened?: ViewExtensionOpened;
// A stack of opened view extensions. The last one is the active one.
viewExtensions: ViewExtensionOpened[];
// Actions
addViewExtension: (viewExtension: ViewExtensionOpened) => void;
closeViewExtension: () => void;
// Helper to clear all extensions (optional, for resetting)
clearViewExtensions: () => void;
// Deprecated: use addViewExtension or closeViewExtension
// We keep the setter signature but it will act on the stack.
// This is to make refactoring easier, but ideally we should rename usages.
setViewExtensionOpened: (showViewExtension?: ViewExtensionOpened) => void;
}
// A Zustand store, like any other.
export const useExtensionStore = create<ExtensionStore>((set) => ({
viewExtensions: [],
addViewExtension: (viewExtension) => {
set((state) => {
// Ensure uniqueness by name (index 0).
// If it exists, remove it first so the new one is added to the end (top of stack).
const name = viewExtension[0];
const others = state.viewExtensions.filter((ext) => ext[0] !== name);
return {
viewExtensions: [...others, viewExtension]
};
});
},
closeViewExtension: () => {
set((state) => {
if (state.viewExtensions.length === 0) return {};
return { viewExtensions: state.viewExtensions.slice(0, -1) };
});
},
clearViewExtensions: () => {
set({ viewExtensions: [] });
},
// Compatibility adapter
setViewExtensionOpened: (viewExtensionOpened) => {
return set({ viewExtensionOpened });
if (viewExtensionOpened) {
set((state) => {
// Same uniqueness logic as addViewExtension
const name = viewExtensionOpened[0];
const others = state.viewExtensions.filter((ext) => ext[0] !== name);
return {
viewExtensions: [...others, viewExtensionOpened]
};
});
} else {
set((state) => {
if (state.viewExtensions.length === 0) return {};
return { viewExtensions: state.viewExtensions.slice(0, -1) };
});
}
},
}));

View File

@@ -232,13 +232,13 @@ export const canNavigateBack = () => {
visibleExtensionDetail,
sourceData,
} = useSearchStore.getState();
const { viewExtensionOpened } = useExtensionStore.getState();
const { viewExtensions } = useExtensionStore.getState();
return (
goAskAi ||
visibleExtensionStore ||
visibleExtensionDetail ||
viewExtensionOpened ||
viewExtensions.length > 0 ||
sourceData
);
};
@@ -254,7 +254,7 @@ export const navigateBack = () => {
setSourceData,
} = useSearchStore.getState();
const { viewExtensionOpened, setViewExtensionOpened } = useExtensionStore.getState();
const { viewExtensions, closeViewExtension } = useExtensionStore.getState();
if (goAskAi) {
return setGoAskAi(false);
@@ -268,8 +268,8 @@ export const navigateBack = () => {
return setVisibleExtensionStore(false);
}
if (viewExtensionOpened) {
setViewExtensionOpened(void 0);
if (viewExtensions.length > 0) {
closeViewExtension();
platformAdapter.emitEvent("refresh-window-size");
return;
}
@@ -304,48 +304,6 @@ export const dispatchEvent = (
target.dispatchEvent(event);
};
export const visibleSearchBar = () => {
const { visibleExtensionDetail } = useSearchStore.getState();
const { viewExtensionOpened } = useExtensionStore.getState();
if (visibleExtensionDetail) return false;
if (isNil(viewExtensionOpened)) return true;
const ui = viewExtensionOpened[4];
return ui?.search_bar ?? false;
};
export const visibleFilterBar = () => {
const {
visibleExtensionStore,
visibleExtensionDetail,
goAskAi,
} = useSearchStore.getState();
const { viewExtensionOpened } = useExtensionStore.getState();
if (visibleExtensionStore || visibleExtensionDetail || goAskAi) return false;
if (isNil(viewExtensionOpened)) return true;
const ui = viewExtensionOpened[4];
return ui?.filter_bar ?? false;
};
export const visibleFooterBar = () => {
const { viewExtensionOpened } = useExtensionStore.getState();
if (isNil(viewExtensionOpened)) return true;
const ui = viewExtensionOpened[4];
return ui?.footer ?? false;
};
export const installExtensionError = (error: any) => {
console.log(error);

View File

@@ -43,6 +43,7 @@ export interface TauriPlatformAdapter extends BasePlatformAdapter {
isWindowResizable: () => Promise<boolean>;
getWindowSize: () => Promise<{ width: number; height: number }>;
setWindowFullscreen: (enable: boolean) => Promise<void>;
isWindowFullscreen: () => Promise<boolean>;
isWindowMaximized: () => Promise<boolean>;
setWindowMaximized: (enable: boolean) => Promise<void>;
getWindowPosition: () => Promise<{ x: number; y: number }>;
@@ -70,6 +71,9 @@ export const createTauriAdapter = (): TauriPlatformAdapter => {
async setWindowFullscreen(enable) {
return windowWrapper.setFullscreen(enable);
},
async isWindowFullscreen() {
return windowWrapper.isFullscreen();
},
async isWindowMaximized() {
return windowWrapper.isMaximized();
},

View File

@@ -16,6 +16,7 @@ export interface WebPlatformAdapter extends BasePlatformAdapter {
isWindowResizable: () => Promise<boolean>;
getWindowSize: () => Promise<{ width: number; height: number }>;
setWindowFullscreen: (enable: boolean) => Promise<void>;
isWindowFullscreen: () => Promise<boolean>;
getMonitorFromCursor: () => Promise<any>;
getWindowPosition: () => Promise<{ x: number; y: number }>;
setWindowPosition: (x: number, y: number) => Promise<void>;
@@ -54,8 +55,13 @@ export const createWebAdapter = (): WebPlatformAdapter => {
return true;
},
async setWindowFullscreen(enable) {
console.log("Web mode simulated fullscreen:", enable);
console.log("Web mode simulated set window fullscreen:", enable);
},
async isWindowFullscreen() {
return false;
},
async getMonitorFromCursor() {
return {
size: {

View File

@@ -48,6 +48,12 @@ export const windowWrapper = {
return win.setFullscreen(enable);
},
async isFullscreen() {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
const win = getCurrentWindow();
return win.isFullscreen();
},
async setLogicalPosition(x: number, y: number) {
const { LogicalPosition } = await import("@tauri-apps/api/dpi");
const window = await this.getCurrentWebviewWindow();