mirror of
https://github.com/infinilabs/coco-app.git
synced 2026-02-24 04:01:27 +01:00
chore: up
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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();
|
||||
|
||||
46
src/hooks/useViewExtensionUI.ts
Normal file
46
src/hooks/useViewExtensionUI.ts
Normal 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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) };
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user