11 Commits

Author SHA1 Message Date
Steve Lau
42e41d2890 release notes 2025-12-24 16:08:18 +08:00
Steve Lau
bc20635e00 feat: support app search even if Spotlight is disabled
Previously, we relied on Spotlight (mdfind) to fetch the app list,
which means it won't work if users disable their Spotlight index.

This commit bumps the applications-rs library, which can now list
apps using `lsregister`, macOS's launch service tool. (See commit [1]
for details). With this, our app search works even tough Spotlight
is disabled.

[1]: ec174b7761
2025-12-24 16:01:55 +08:00
BiggerRain
03af1d46c5 fix: skip window resize when UI size is missing (#1023)
* fix: skip window resize when UI size is missing

* chore: padding

* chore: update

* refactor: update

* chore: height

* chore: height

* chore: defalut value

---------

Co-authored-by: ayang <473033518@qq.com>
2025-12-23 22:02:19 +08:00
SteveLauC
8638724e68 chore: remove file foo (#1026)
It was a placeholder that I added in PR #1009, I forgot to remove it
before merging that PR. Let's remove it now.
2025-12-22 16:12:14 +08:00
ayangweb
6ae2ed0832 fix: esc key fails to close the popover (#1024)
* fix: esc key fails to close the popover

* refactor: update
2025-12-22 11:51:04 +08:00
Hardy
81dab997a9 chore: update release notes for publish 0.10.0-2619 (#1021)
* chore: update release notes for publish 0.10.0-2619

* Add section headers to release notes

Added section headers for breaking changes, features, bug fixes, and improvements.

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: BiggerRain <15911122312@163.COM>
2025-12-22 09:51:59 +08:00
BiggerRain
7a4364665e chore: bump version to 0.10.0 (#1022) 2025-12-19 17:56:32 +08:00
BiggerRain
444ba968c4 chore: hide selection settings (#1020) 2025-12-19 16:15:03 +08:00
ayangweb
206d8db4f4 refactor: disable input auto-correction (#1019) 2025-12-19 15:46:53 +08:00
BiggerRain
7e40632c29 style: adjust delete button color (#1018) 2025-12-19 12:59:58 +08:00
SteveLauC
f483ce4887 feat: resizable extension UI (#1009)
* wip

* define config entries: width/height/resizable/detachable

* chore: window size

* fix: add default values for ViewExtensionUiSettings fields

* chore: open

* chore: add window size set

* wip

* chore: window size

* define config entries: width/height/resizable/detachable

* chore: open

* fix: add default values for ViewExtensionUiSettings fields

* chore: add window size set

* chore: up

* fix: consle error

* chore: up

* chore: up

* chore: up

* chore: up

* refactor: update

* fix: page error about install

* chore: up

* chore: ci error

* docs: update release notes

* style: adjust styles

---------

Co-authored-by: rain9 <15911122312@163.com>
Co-authored-by: ayang <473033518@qq.com>
2025-12-19 09:01:51 +08:00
37 changed files with 678 additions and 147 deletions

2
.gitignore vendored
View File

@@ -29,4 +29,4 @@ web.md
*.sw?
.env
.trae
.trae

View File

@@ -10,6 +10,7 @@
"dataurl",
"deeplink",
"deepthink",
"Detch",
"dtolnay",
"dyld",
"elif",
@@ -56,6 +57,7 @@
"rgba",
"rustup",
"screenshotable",
"seprate",
"serde",
"Shadcn",
"swatinem",

View File

@@ -7,12 +7,25 @@ title: "Release Notes"
Information about release notes of Coco App is provided here.
## Latest (In development)
## Latest (In development)
### ❌ Breaking changes
### 🚀 Features
- feat: support app search even if Spotlight is disabled #1028
### 🐛 Bug fix
### ✈️ Improvements
## 0.10.0 (2025-12-19)
### ❌ Breaking changes
### 🚀 Features
- feat: resizable extension UI #1009
- feat: add open button to launch installed extension #1013
### 🐛 Bug fix

View File

@@ -1,7 +1,7 @@
{
"name": "coco",
"private": true,
"version": "0.9.1",
"version": "0.10.0",
"type": "module",
"scripts": {
"dev": "vite",

16
src-tauri/Cargo.lock generated
View File

@@ -332,7 +332,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "applications"
version = "0.3.1"
source = "git+https://github.com/infinilabs/applications-rs?rev=b5fac4034a40d42e72f727f1aa1cc1f19fe86653#b5fac4034a40d42e72f727f1aa1cc1f19fe86653"
source = "git+https://github.com/infinilabs/applications-rs?rev=ec174b7761bfa5eb7af0a93218b014e2d1505643#ec174b7761bfa5eb7af0a93218b014e2d1505643"
dependencies = [
"anyhow",
"core-foundation 0.9.4",
@@ -1132,7 +1132,7 @@ dependencies = [
[[package]]
name = "coco"
version = "0.9.1"
version = "0.10.0"
dependencies = [
"actix-files",
"actix-web",
@@ -1184,6 +1184,7 @@ dependencies = [
"scraper",
"semver",
"serde",
"serde-inline-default",
"serde_json",
"serde_plain",
"snafu",
@@ -6308,6 +6309,17 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-inline-default"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d48532bc0781ac622a5fea0f16502d3b4f1af0fcebe56d618120969f35d315"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "serde-untagged"
version = "0.1.9"

View File

@@ -1,6 +1,6 @@
[package]
name = "coco"
version = "0.9.1"
version = "0.10.0"
description = "Search, connect, collaborate all in one place."
authors = ["INFINI Labs"]
edition = "2024"
@@ -62,7 +62,7 @@ tauri-plugin-drag = "2"
tauri-plugin-macos-permissions = "2"
tauri-plugin-fs-pro = "2"
tauri-plugin-screenshots = "2"
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "b5fac4034a40d42e72f727f1aa1cc1f19fe86653" }
applications = { git = "https://github.com/infinilabs/applications-rs", rev = "ec174b7761bfa5eb7af0a93218b014e2d1505643" }
tokio-native-tls = "0.3" # For wss connections
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.20", features = ["native-tls"] }
@@ -122,6 +122,7 @@ actix-web = "4.11.0"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-zustand = "1"
snafu = "0.8.9"
serde-inline-default = "1.0.0"
[dev-dependencies]
tempfile = "3.23.0"

View File

@@ -31,6 +31,11 @@
"core:window:deny-internal-toggle-maximize",
"core:window:allow-set-shadow",
"core:window:allow-set-position",
"core:window:allow-set-theme",
"core:window:allow-unminimize",
"core:window:allow-set-fullscreen",
"core:window:allow-set-resizable",
"core:window:allow-maximize",
"core:app:allow-set-app-theme",
"shell:default",
"http:default",
@@ -65,12 +70,10 @@
"fs-pro:default",
"macos-permissions:default",
"screenshots:default",
"core:window:allow-set-theme",
"process:default",
"updater:default",
"windows-version:default",
"log:default",
"opener:default",
"core:window:allow-unminimize"
"opener:default"
]
}

View File

@@ -152,14 +152,31 @@ pub struct Extension {
}
/// Settings that control the built-in UI Components
#[serde_inline_default::serde_inline_default]
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub(crate) struct ViewExtensionUISettings {
/// Show the search bar
#[serde_inline_default(false)]
search_bar: bool,
/// Show the filter bar
#[serde_inline_default(false)]
filter_bar: bool,
/// Show the footer
#[serde_inline_default(false)]
footer: bool,
/// The recommended width of the window for this extension
width: Option<u32>,
/// The recommended heigh of the window for this extension
height: Option<u32>,
/// Is the extension window's size adjustable?
#[serde_inline_default(false)]
resizable: bool,
/// Detch the extension window from Coco's main window.
///
/// If true, user can click the detach button to open this
/// extension in a seprate window.
#[serde_inline_default(false)]
detachable: bool,
}
/// Bundle ID uniquely identifies an extension.

View File

@@ -29,7 +29,6 @@ use tauri_plugin_autostart::MacosLauncher;
/// Tauri store name
pub(crate) const COCO_TAURI_STORE: &str = "coco_tauri_store";
pub(crate) const WINDOW_CENTER_BASELINE_HEIGHT: i32 = 590;
lazy_static! {
static ref PREVIOUS_MONITOR_NAME: Mutex<Option<String>> = Mutex::new(None);
@@ -44,37 +43,6 @@ lazy_static! {
/// you access it.
pub(crate) static GLOBAL_TAURI_APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
#[tauri::command]
async fn change_window_height(handle: AppHandle, height: u32) {
let window: WebviewWindow = handle.get_webview_window(MAIN_WINDOW_LABEL).unwrap();
let mut size = window.outer_size().unwrap();
size.height = height;
window.set_size(size).unwrap();
// Center the window horizontally and vertically based on the baseline height of 590
let monitor = window.primary_monitor().ok().flatten().or_else(|| {
window
.available_monitors()
.ok()
.and_then(|ms| ms.into_iter().next())
});
if let Some(monitor) = monitor {
let monitor_position = monitor.position();
let monitor_size = monitor.size();
let outer_size = window.outer_size().unwrap();
let window_width = outer_size.width as i32;
let x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;
let y =
monitor_position.y + (monitor_size.height as i32 - WINDOW_CENTER_BASELINE_HEIGHT) / 2;
let _ = window.set_position(PhysicalPosition::new(x, y));
}
}
// Removed unused Payload to avoid unnecessary serde derive macro invocations
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -123,7 +91,6 @@ pub fn run() {
let app = app_builder
.invoke_handler(tauri::generate_handler![
change_window_height,
shortcut::change_shortcut,
shortcut::unregister_shortcut,
shortcut::get_current_shortcut,
@@ -380,12 +347,13 @@ fn move_window_to_active_monitor(window: &WebviewWindow) {
return;
}
};
let window_width = window_size.width as i32;
let window_height = 590 * scale_factor as i32;
// Horizontal center uses actual width, vertical center uses 590 baseline
let window_x = monitor_position.x + (monitor_size.width as i32 - window_width) / 2;
let window_y = monitor_position.y
+ (monitor_size.height as i32 - WINDOW_CENTER_BASELINE_HEIGHT) / 2;
let window_y = monitor_position.y + (monitor_size.height as i32 - window_height) / 2;
if let Err(e) = window.set_position(PhysicalPosition::new(window_x, window_y)) {
log::error!("Failed to move window: {}", e);

View File

@@ -32,7 +32,7 @@ pub fn platform(
let panel = main_window.to_panel::<NsPanel>().unwrap();
// set level
panel.set_level(PanelLevel::Utility.value());
panel.set_level(PanelLevel::Dock.value());
// Do not steal focus from other windows
panel.set_style_mask(StyleMask::empty().nonactivating_panel().into());

View File

@@ -20,7 +20,7 @@
"width": 680,
"decorations": false,
"minimizable": false,
"maximizable": false,
"maximizable": true,
"skipTaskbar": true,
"resizable": false,
"acceptFirstMouse": true,

View File

@@ -15,12 +15,12 @@ import { useConnectStore } from "@/stores/connectStore";
import FontIcon from "@/components/Common/Icons/FontIcon";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import NoDataImage from "@/components/Common/NoDataImage";
import PopoverInput from "@/components/Common/PopoverInput";
import { AssistantFetcher } from "./AssistantFetcher";
import AssistantItem from "./AssistantItem";
import Pagination from "@/components/Common/Pagination";
import { useSearchStore } from "@/stores/searchStore";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
interface AssistantListProps {
assistantIDs?: string[];
@@ -241,9 +241,10 @@ export function AssistantList({ assistantIDs = [] }: AssistantListProps) {
searchInputRef.current?.focus();
}}
>
<PopoverInput
<Input
ref={searchInputRef}
autoFocus
autoCorrect="off"
value={keyword}
placeholder={t("assistant.popover.search")}
className="w-full h-8"

View File

@@ -63,6 +63,7 @@ export function Connect({ setIsConnect, onAddServer }: ConnectServiceProps) {
type="text"
id="endpoint"
value={endpointLink}
autoCorrect="off"
placeholder={t("cloud.connect.serverPlaceholder")}
onChange={onChangeEndpoint}
className="text-[#101010] dark:text-white flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:bg-gray-800"

View File

@@ -57,13 +57,13 @@ const ErrorNotification = ({
>
<div className="flex items-center">
{visibleError.type === "error" && (
<AlertCircle className="w-5 h-5 text-red-500 mr-2" />
<AlertCircle className="size-5 shrink-0 text-red-500 mr-2" />
)}
{visibleError.type === "warning" && (
<AlertTriangle className="w-5 h-5 text-yellow-500 mr-2" />
<AlertTriangle className="size-5 shrink-0 text-yellow-500 mr-2" />
)}
{visibleError.type === "info" && (
<Info className="w-5 h-5 text-blue-500 mr-2" />
<Info className="size-5 shrink-0 text-blue-500 mr-2" />
)}
<span className="text-sm text-gray-700 dark:text-gray-200">
@@ -78,7 +78,7 @@ const ErrorNotification = ({
</div>
<X
className="w-5 h-5 ml-4 cursor-pointer text-gray-400 hover:text-gray-600"
className="size-5 shrink-0 ml-4 cursor-pointer text-gray-400 hover:text-gray-600"
onClick={() => removeError(visibleError.id)}
/>
</div>

View File

@@ -1,36 +0,0 @@
import type { InputProps } from "@/components/ui/input";
import { Input } from "@/components/ui/input";
import { useKeyPress } from "ahooks";
import { forwardRef, useImperativeHandle, useRef } from "react";
import { POPOVER_PANEL_SELECTOR } from "@/constants";
const PopoverInput = forwardRef<HTMLInputElement, InputProps>((props, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => inputRef.current!);
useKeyPress(
"esc",
(event) => {
if (inputRef.current === document.activeElement) {
event.preventDefault();
event.stopPropagation();
inputRef.current?.blur();
const parentPanel = inputRef.current?.closest(POPOVER_PANEL_SELECTOR);
if (parentPanel instanceof HTMLElement) {
parentPanel.focus();
}
}
},
{
target: inputRef,
}
);
return <Input autoCorrect="off" ref={inputRef} {...(props as any)} />;
});
export default PopoverInput;

View File

@@ -1,9 +1,11 @@
import { FC, HTMLAttributes, useEffect, useRef, useState } from "react";
import { useKeyPress } from "ahooks";
import clsx from "clsx";
import { last } from "lodash-es";
import { POPOVER_PANEL_SELECTOR } from "@/constants";
import {
OPENED_POPOVER_TRIGGER_SELECTOR,
POPOVER_PANEL_SELECTOR,
} from "@/constants";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { useAppStore } from "@/stores/appStore";
import { KeyType } from "ahooks/lib/useKeyPress";
@@ -43,22 +45,21 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
const [visibleShortcut, setVisibleShortcut] = useState<boolean>();
useEffect(() => {
const popoverPanelEls = document.querySelectorAll(POPOVER_PANEL_SELECTOR);
const popoverPanelEl = last(popoverPanelEls);
const popoverPanelEl = document.querySelector(POPOVER_PANEL_SELECTOR);
const openedPopoverTriggerEl = document.querySelector(
OPENED_POPOVER_TRIGGER_SELECTOR
);
if (!openPopover || !popoverPanelEl) {
return setVisibleShortcut(modifierKeyPressed);
}
const popoverButtonEl = document.querySelector(
`[aria-controls="${popoverPanelEl.id}"]`
const isChildInPanel = popoverPanelEl?.contains(childrenRef.current);
const isChildInTrigger = openedPopoverTriggerEl?.contains(
childrenRef.current
);
const isChildInPanel = popoverPanelEl?.contains(childrenRef.current);
const isChildInButton = popoverButtonEl?.contains(childrenRef.current);
const isChildInPopover = isChildInPanel || isChildInButton;
const isChildInPopover = isChildInPanel || isChildInTrigger;
setVisibleShortcut(isChildInPopover && modifierKeyPressed);
}, [openPopover, modifierKeyPressed]);
@@ -111,7 +112,7 @@ const VisibleKey: FC<VisibleKeyProps> = (props) => {
{showTooltip && visibleShortcut ? (
<div
className={clsx(
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-[14px] bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
"size-4 flex items-center justify-center font-normal text-xs text-[#333] leading-3.5 bg-[#ccc] dark:bg-[#6B6B6B] rounded-md shadow-[-6px_0px_6px_2px_#fff] dark:shadow-[-6px_0px_6px_2px_#000] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
shortcutClassName
)}
>

View File

@@ -1,5 +1,6 @@
import { useCallback, useRef, useMemo, useState, useEffect } from "react";
import { cloneDeep, isEmpty } from "lodash-es";
import { useKeyPress } from "ahooks";
import { useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
@@ -8,7 +9,6 @@ import { Get } from "@/api/axiosRequest";
import type { Assistant } from "@/types/chat";
import { useAppStore } from "@/stores/appStore";
import { canNavigateBack, navigateBack } from "@/utils";
import { useKeyPress } from "ahooks";
import { useShortcutsStore } from "@/stores/shortcutsStore";
interface AssistantManagerProps {
@@ -167,7 +167,7 @@ export function useAssistantManager({
const { selectedSearchContent, visibleExtensionStore } =
useSearchStore.getState();
console.log("selectedSearchContent", selectedSearchContent);
// console.log("selectedSearchContent", selectedSearchContent);
const { id, type, category } = selectedSearchContent ?? {};

View File

@@ -118,7 +118,7 @@ const ExtensionDetail: FC<ExtensionDetailProps> = (props) => {
</div>
<div className="flex items-center gap-1">
<FolderDown className="size-4" />
<span>{selectedExtension.stats.installs}</span>
<span>{selectedExtension.stats?.installs ?? 0}</span>
</div>
</div>
</div>
@@ -243,7 +243,7 @@ const ExtensionDetail: FC<ExtensionDetailProps> = (props) => {
}}
deleteButtonProps={{
className:
"!text-[#FF4949] bg-[#F8F9FA] dark:text-white dark:bg-[#202126] border-[#E6E6E6] dark:border-white/10",
"text-white bg-[#FF4949] hover:bg-[#FF4949] border-[#E6E6E6] dark:border-white/10",
}}
setIsOpen={setIsOpen}
onCancel={handleCancel}

View File

@@ -348,7 +348,7 @@ const ExtensionStore = ({
<div className="flex items-center gap-1 text-[#999]">
<FolderDown className="size-4" />
<span>{stats.installs}</span>
<span>{stats?.installs ?? 0}</span>
</div>
</div>
</div>

View File

@@ -14,7 +14,7 @@ import { useShortcutsStore } from "@/stores/shortcutsStore";
import { useAppStore } from "@/stores/appStore";
import { useSearchStore } from "@/stores/searchStore";
import { useExtensionsStore } from "@/stores/extensionsStore";
import { parseSearchQuery, SearchQuery } from "@/utils";
import { parseSearchQuery, SearchQuery, canNavigateBack } from "@/utils";
import InputUpload from "./InputUpload";
import Copyright from "../Common/Copyright";
@@ -283,7 +283,7 @@ const InputControls = ({
</div>
)}
{isChatPage || hasModules?.length !== 2 ? null : (
{isChatPage || hasModules?.length !== 2 || canNavigateBack() ? null : (
<div className="relative w-16 flex justify-end items-center">
<div className="absolute right-[52px] -top-2 z-10">
<VisibleKey

View File

@@ -17,10 +17,10 @@ import Checkbox from "@/components/Common/Checkbox";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import VisibleKey from "@/components/Common/VisibleKey";
import NoDataImage from "@/components/Common/NoDataImage";
import PopoverInput from "@/components/Common/PopoverInput";
import Pagination from "@/components/Common/Pagination";
import { SearchQuery } from "@/utils";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
interface MCPPopoverProps {
mcp_servers: any;
@@ -263,8 +263,9 @@ export default function MCPPopover({
/>
</div>
<PopoverInput
<Input
autoFocus
autoCorrect="off"
value={keyword}
ref={searchInputRef}
className="size-full px-2 rounded-lg border dark:border-white/10 bg-transparent"

View File

@@ -17,9 +17,9 @@ import Checkbox from "@/components/Common/Checkbox";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import VisibleKey from "@/components/Common/VisibleKey";
import NoDataImage from "@/components/Common/NoDataImage";
import PopoverInput from "@/components/Common/PopoverInput";
import Pagination from "@/components/Common/Pagination";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
interface SearchPopoverProps {
datasource: any;
@@ -271,8 +271,9 @@ export default function SearchPopover({
/>
</div>
<PopoverInput
<Input
autoFocus
autoCorrect="off"
value={keyword}
ref={searchInputRef}
className="size-full px-2 rounded-lg border dark:border-white/10 bg-transparent"

View File

@@ -1,19 +1,52 @@
import React from "react";
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Maximize2, Minimize2, Focus } from "lucide-react";
import { useSearchStore } from "@/stores/searchStore";
import {
ExtensionFileSystemPermission,
FileSystemAccess,
ViewExtensionUISettingsOrNull,
} from "../Settings/Extensions";
import platformAdapter from "@/utils/platformAdapter";
import { useShortcutsStore } from "@/stores/shortcutsStore";
import { isMac } from "@/utils/platform";
import { useAppStore } from "@/stores/appStore";
const ViewExtension: React.FC = () => {
const { viewExtensionOpened } = useSearchStore();
const isTauri = useAppStore((state) => state.isTauri);
// Complete list of the backend APIs, grouped by their category.
const [apis, setApis] = useState<Map<string, string[]> | null>(null);
const { setModifierKeyPressed } = useShortcutsStore();
const { t } = useTranslation();
const [isFullscreen, setIsFullscreen] = useState(false);
const prevWindowRef = useRef<{
width: number;
height: number;
resizable: boolean;
x: number;
y: number;
} | null>(null);
const fullscreenPrevRef = useRef<{
width: number;
height: number;
resizable: boolean;
x: number;
y: number;
} | null>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [scale, setScale] = useState(1);
const [fallbackViewSize, setFallbackViewSize] = useState<{
width: number;
height: number;
} | null>(() => {
if (typeof window === "undefined") return null;
return { width: window.innerWidth, height: window.innerHeight };
});
if (viewExtensionOpened == null) {
// When this view gets loaded, this state should not be NULL.
@@ -156,7 +189,6 @@ const ViewExtension: React.FC = () => {
}
};
window.addEventListener("message", messageHandler);
console.info("Coco extension API listener is up");
return () => {
window.removeEventListener("message", messageHandler);
@@ -164,15 +196,261 @@ const ViewExtension: React.FC = () => {
}, [reversedApis, permission]); // Add apiPermissions as dependency
const fileUrl = viewExtensionOpened[2];
const ui: ViewExtensionUISettingsOrNull = useMemo(() => {
return viewExtensionOpened[4] as ViewExtensionUISettingsOrNull;
}, [viewExtensionOpened]);
const resizable = ui?.resizable;
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 baseWidth = useMemo(() => {
if (uiWidth != null) return uiWidth;
if (fallbackViewSize != null) return fallbackViewSize.width;
return 0;
}, [uiWidth, fallbackViewSize]);
const baseHeight = useMemo(() => {
if (uiHeight != null) return uiHeight;
if (fallbackViewSize != null) return fallbackViewSize.height;
return 0;
}, [uiHeight, fallbackViewSize]);
const recomputeScale = useCallback(async () => {
if (!hasExplicitWindowSize) {
setScale(1);
return;
}
const size = await platformAdapter.getWindowSize();
const nextScale = Math.min(
size.width / baseWidth,
size.height / baseHeight
);
setScale(Math.max(nextScale, 0.1));
}, [hasExplicitWindowSize, baseWidth, baseHeight]);
const applyFullscreen = useCallback(
async (next: boolean) => {
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 {
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);
await platformAdapter.setWindowPosition(prev.x, prev.y);
fullscreenPrevRef.current = null;
await recomputeScale();
} else if (hasExplicitWindowSize) {
const nextResizable =
ui && typeof ui.resizable === "boolean" ? ui.resizable : true;
await platformAdapter.setWindowSize(uiWidth, uiHeight);
await platformAdapter.setWindowResizable(nextResizable);
await platformAdapter.centerOnCurrentMonitor();
await recomputeScale();
} else {
await recomputeScale();
}
setTimeout(() => {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}, 0);
}
},
[ui, recomputeScale]
);
useEffect(() => {
const applyWindowSettings = async () => {
if (viewExtensionOpened != null) {
const size = await platformAdapter.getWindowSize();
const resizable = await platformAdapter.isWindowResizable();
const pos = await platformAdapter.getWindowPosition();
setFallbackViewSize({ width: size.width, height: size.height });
prevWindowRef.current = {
width: size.width,
height: size.height,
resizable,
x: pos.x,
y: pos.y,
};
if (hasExplicitWindowSize) {
const nextResizable =
ui && typeof ui.resizable === "boolean" ? ui.resizable : true;
await platformAdapter.setWindowSize(uiWidth, uiHeight);
await platformAdapter.setWindowResizable(nextResizable);
await platformAdapter.centerOnCurrentMonitor();
await recomputeScale();
} else {
await recomputeScale();
}
setTimeout(() => {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}, 0);
} else {
if (prevWindowRef.current) {
const prev = prevWindowRef.current;
await platformAdapter.setWindowSize(prev.width, prev.height);
await platformAdapter.setWindowResizable(prev.resizable);
await platformAdapter.centerOnCurrentMonitor();
prevWindowRef.current = null;
await recomputeScale();
setTimeout(() => {
iframeRef.current?.focus();
}, 0);
}
}
};
applyWindowSettings();
return () => {
if (prevWindowRef.current) {
const prev = prevWindowRef.current;
platformAdapter.setWindowSize(prev.width, prev.height);
platformAdapter.setWindowResizable(prev.resizable);
platformAdapter.centerOnCurrentMonitor();
prevWindowRef.current = null;
}
};
}, [
viewExtensionOpened,
ui,
hasExplicitWindowSize,
uiWidth,
uiHeight,
recomputeScale,
]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && isFullscreen) {
applyFullscreen(false);
setIsFullscreen(false);
}
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => {
window.removeEventListener("keydown", handleKeyDown, {
capture: true,
} as any);
};
}, [isFullscreen, applyFullscreen]);
return (
<iframe
src={fileUrl}
className="w-full h-full border-0"
onLoad={(event) => {
event.currentTarget.focus();
}}
/>
<div className="relative w-full h-full">
{resizable && (
<button
aria-label={
isFullscreen
? t("viewExtension.fullscreen.exit")
: t("viewExtension.fullscreen.enter")
}
className="absolute top-2 right-2 z-10 rounded-md bg-black/40 text-white p-2 hover:bg-black/60 focus:outline-none"
onClick={async () => {
const next = !isFullscreen;
await applyFullscreen(next);
setIsFullscreen(next);
if (next) {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}
}}
>
{isFullscreen ? (
<Minimize2 className="size-4" />
) : (
<Maximize2 className="size-4" />
)}
</button>
)}
{/* Focus helper button */}
{resizable && (
<button
aria-label={t("viewExtension.focus")}
className="absolute top-2 right-12 z-10 rounded-md bg-black/40 text-white p-2 hover:bg-black/60 focus:outline-none"
onClick={() => {
iframeRef.current?.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}}
>
<Focus className="size-4" />
</button>
)}
<div
className="w-full h-full flex items-center justify-center"
onMouseDownCapture={() => {
iframeRef.current?.focus();
}}
onPointerDown={() => {
iframeRef.current?.focus();
}}
onClickCapture={() => {
iframeRef.current?.focus();
}}
>
<iframe
ref={iframeRef}
src={fileUrl}
className="border-0 w-full h-full"
style={{
transform: `scale(${scale})`,
transformOrigin: "center center",
outline: "none",
}}
allow="fullscreen; pointer-lock; gamepad"
allowFullScreen
tabIndex={-1}
onLoad={(event) => {
event.currentTarget.focus();
try {
iframeRef.current?.contentWindow?.focus();
} catch {}
}}
/>
</div>
</div>
);
};

View File

@@ -110,9 +110,13 @@ function SearchChat({
let collapseWindowTimer = useRef<ReturnType<typeof setTimeout>>();
const setWindowSize = useCallback(() => {
const { viewExtensionOpened } = useSearchStore.getState();
if (collapseWindowTimer.current) {
clearTimeout(collapseWindowTimer.current);
}
if (viewExtensionOpened != null) {
return;
}
const width = 680;
let height = WINDOW_CENTER_BASELINE_HEIGHT;
@@ -177,6 +181,25 @@ function SearchChat({
onFocus: debouncedSetWindowSize,
});
useEffect(() => {
const unlisten = platformAdapter.listenEvent("refresh-window-size", () => {
debouncedSetWindowSize();
});
return () => {
unlisten
.then((fn) => {
try {
typeof fn === "function" && fn();
} catch {
// ignore
}
})
.catch(() => {
// ignore
});
};
}, [debouncedSetWindowSize]);
useEffect(() => {
dispatch({
type: "SET_SEARCH_ACTIVE",
@@ -386,7 +409,7 @@ function SearchChat({
<div
data-tauri-drag-region={isTauri}
className={clsx(
"m-auto overflow-hidden relative bg-no-repeat flex flex-col",
"m-auto overflow-hidden relative bg-no-repeat flex flex-col bg-cover",
[
isTransitioned
? "bg-bottom bg-[url('/assets/chat_bg_light.png')] dark:bg-[url('/assets/chat_bg_dark.png')]"
@@ -401,7 +424,6 @@ function SearchChat({
}
)}
style={{
backgroundSize: "auto 590px",
opacity: blurred ? blurOpacity / 100 : normalOpacity / 100,
}}
>

View File

@@ -21,8 +21,8 @@ import SettingsInput from "@/components//Settings/SettingsInput";
import platformAdapter from "@/utils/platformAdapter";
import UpdateSettings from "./components/UpdateSettings";
import SettingsToggle from "../SettingsToggle";
import SelectionSettings from "./components/Selection";
import { isMac } from "@/utils/platform";
// import SelectionSettings from "./components/Selection";
// import { isMac } from "@/utils/platform";
import {
Select,
SelectTrigger,
@@ -196,7 +196,7 @@ const Advanced = () => {
})}
</div>
{isMac && <SelectionSettings />}
{/* {isMac && <SelectionSettings />} */}
<Shortcuts />

View File

@@ -75,8 +75,14 @@ export interface ViewExtensionUISettings {
search_bar: boolean;
filter_bar: boolean;
footer: boolean;
width: number | null;
height: number | null;
resizable: boolean;
detachable: boolean;
}
export type ViewExtensionUISettingsOrNull = ViewExtensionUISettings | null | undefined;
export interface Extension {
id: ExtensionId;
type: ExtensionType;

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
import { OPENED_POPOVER_TRIGGER_SELECTOR } from "@/constants";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
@@ -34,6 +35,24 @@ const PopoverContent = React.forwardRef<
)}
data-popover-panel
id={panelId}
onEscapeKeyDown={(event) => {
event.stopPropagation();
event.preventDefault();
if (
document.activeElement instanceof HTMLInputElement ||
document.activeElement instanceof HTMLTextAreaElement
) {
return document.activeElement.blur();
}
const el = document.querySelector(OPENED_POPOVER_TRIGGER_SELECTOR);
if (el instanceof HTMLElement) {
el.click();
}
}}
{...props}
/>
)

View File

@@ -1,8 +1,11 @@
export const POPOVER_PANEL_SELECTOR = '[data-popover-panel]';
export const POPOVER_PANEL_SELECTOR = "[data-radix-popper-content-wrapper]";
export const HISTORY_PANEL_ID = "headlessui-popover-panel:history-panel";
export const OPENED_POPOVER_TRIGGER_SELECTOR =
"[aria-haspopup='dialog'][aria-expanded='true'][data-state='open']";
export const CONTEXT_MENU_PANEL_ID = "headlessui-popover-panel:context-menu";
export const HISTORY_PANEL_ID = "popover-panel:history-panel";
export const CONTEXT_MENU_PANEL_ID = "popover-panel:context-menu";
export const DEFAULT_COCO_SERVER_ID = "default_coco_server";

View File

@@ -5,17 +5,15 @@ import { HISTORY_PANEL_ID } from "@/constants";
import { closeHistoryPanel } from "@/utils";
const useEscape = () => {
const visibleContextMenu = useSearchStore((state) => {
return state.visibleContextMenu;
});
const setVisibleContextMenu = useSearchStore((state) => {
return state.setVisibleContextMenu;
});
const { setVisibleContextMenu } = useSearchStore();
useKeyPress("esc", (event) => {
event.preventDefault();
event.stopPropagation();
const { visibleContextMenu, viewExtensionOpened } =
useSearchStore.getState();
if (
document.activeElement instanceof HTMLInputElement ||
document.activeElement instanceof HTMLTextAreaElement
@@ -33,6 +31,10 @@ const useEscape = () => {
return closeHistoryPanel();
}
if (viewExtensionOpened != null) {
return;
}
platformAdapter.hideWindow();
});
};

View File

@@ -16,8 +16,9 @@ export const useModifierKeyPress = () => {
useKeyPress(
modifierKey,
(event) => {
const popoverPanelEl = document.querySelector(POPOVER_PANEL_SELECTOR);
setOpenPopover(Boolean(popoverPanelEl));
const el = document.querySelector(POPOVER_PANEL_SELECTOR);
setOpenPopover(Boolean(el));
setModifierKeyPressed(event.type === "keydown");
},

View File

@@ -626,9 +626,16 @@
},
"deleteDialog": {
"title": "Uninstall",
"description": "This will remove all the data and commands associated with this extension."
"description": "This will delete all data and commands related to the extension."
}
},
"viewExtension": {
"fullscreen": {
"enter": "Enter Full Screen",
"exit": "Exit Full Screen"
},
"focus": "Focus"
},
"deleteDialog": {
"button": {
"cancel": "Cancel",

View File

@@ -628,6 +628,13 @@
"description": "这将删除与该扩展相关的所有数据和命令。"
}
},
"viewExtension": {
"fullscreen": {
"enter": "进入全屏",
"exit": "退出全屏"
},
"focus": "聚焦"
},
"deleteDialog": {
"button": {
"cancel": "取消",

View File

@@ -12,6 +12,7 @@ import { ViewExtensionOpened } from "@/stores/searchStore";
export interface EventPayloads {
"theme-changed": string;
"tauri://focus": void;
"refresh-window-size": void;
"endpoint-changed": {
endpoint: string;
endpoint_http: string;

View File

@@ -258,7 +258,9 @@ export const navigateBack = () => {
}
if (viewExtensionOpened) {
return setViewExtensionOpened(void 0);
setViewExtensionOpened(void 0);
platformAdapter.emitEvent("refresh-window-size");
return;
}
setSourceData(void 0);
@@ -301,7 +303,7 @@ export const visibleSearchBar = () => {
const ui = viewExtensionOpened[4];
return ui?.search_bar ?? true;
return ui?.search_bar ?? false;
};
export const visibleFilterBar = () => {
@@ -314,7 +316,7 @@ export const visibleFilterBar = () => {
const ui = viewExtensionOpened[4];
return ui?.filter_bar ?? true;
return ui?.filter_bar ?? false;
};
export const visibleFooterBar = () => {
@@ -324,7 +326,7 @@ export const visibleFooterBar = () => {
const ui = viewExtensionOpened[4];
return ui?.footer ?? true;
return ui?.footer ?? false;
};
export const installExtensionError = (error: any) => {

View File

@@ -16,8 +16,16 @@ import { useAppearanceStore } from "@/stores/appearanceStore";
import { copyToClipboard, dispatchEvent, OpenURLWithBrowser } from ".";
import { useAppStore } from "@/stores/appStore";
import { unrequitable } from "@/utils";
import { WebviewWindow } from "@tauri-apps/api/webviewWindow";
import { Theme } from "@tauri-apps/api/window";
import {
getCurrentWebviewWindow,
WebviewWindow,
} from "@tauri-apps/api/webviewWindow";
import {
cursorPosition,
Monitor,
monitorFromPoint,
Theme,
} from "@tauri-apps/api/window";
export interface TauriPlatformAdapter extends BasePlatformAdapter {
openFileDialog: (
@@ -30,13 +38,65 @@ export interface TauriPlatformAdapter extends BasePlatformAdapter {
getWindowTheme: () => Promise<Theme | null>;
setWindowTheme: (theme: Theme | null) => Promise<void>;
getAllWindows: () => Promise<WebviewWindow[]>;
setWindowResizable: (resizable: boolean) => Promise<void>;
isWindowResizable: () => Promise<boolean>;
getWindowSize: () => Promise<{ width: number; height: number }>;
setWindowFullscreen: (enable: boolean) => Promise<void>;
isWindowMaximized: () => Promise<boolean>;
setWindowMaximized: (enable: boolean) => Promise<void>;
getWindowPosition: () => Promise<{ x: number; y: number }>;
setWindowPosition: (x: number, y: number) => Promise<void>;
centerWindow: () => Promise<void>;
getMonitorFromCursor: () => Promise<Monitor | null>;
centerOnCurrentMonitor: () => Promise<unknown>;
}
// Create Tauri adapter functions
export const createTauriAdapter = (): TauriPlatformAdapter => {
return {
async setWindowSize(width, height) {
return windowWrapper.setSize(width, height);
return windowWrapper.setLogicalSize(width, height);
},
async getWindowSize() {
return windowWrapper.getLogicalSize();
},
async setWindowResizable(resizable) {
return windowWrapper.setResizable(resizable);
},
async isWindowResizable() {
return windowWrapper.isResizable();
},
async setWindowFullscreen(enable) {
return windowWrapper.setFullscreen(enable);
},
async isWindowMaximized() {
return windowWrapper.isMaximized();
},
async setWindowMaximized(enable) {
return windowWrapper.setMaximized(enable);
},
async getWindowPosition() {
return windowWrapper.getLogicalPosition();
},
async setWindowPosition(x, y) {
return windowWrapper.setLogicalPosition(x, y);
},
async centerWindow() {
return windowWrapper.center();
},
async getMonitorFromCursor() {
const appWindow = getCurrentWebviewWindow();
const factor = await appWindow.scaleFactor();
const point = await cursorPosition();
const { x, y } = point.toLogical(factor);
return monitorFromPoint(x, y);
},
async centerOnCurrentMonitor() {
return windowWrapper.centerOnMonitor();
},
async hideWindow() {

View File

@@ -12,6 +12,14 @@ export interface WebPlatformAdapter extends BasePlatformAdapter {
getWindowTheme: () => Promise<string>;
setWindowTheme: (theme: string | null) => Promise<void>;
getAllWindows: () => Promise<any[]>;
setWindowResizable: (resizable: boolean) => Promise<void>;
isWindowResizable: () => Promise<boolean>;
getWindowSize: () => Promise<{ width: number; height: number }>;
setWindowFullscreen: (enable: boolean) => Promise<void>;
getMonitorFromCursor: () => Promise<any>;
centerOnCurrentMonitor: () => Promise<void>;
getWindowPosition: () => Promise<{ x: number; y: number }>;
setWindowPosition: (x: number, y: number) => Promise<void>;
}
// Create Web adapter functions
@@ -35,6 +43,46 @@ export const createWebAdapter = (): WebPlatformAdapter => {
console.log(`Web mode simulated window resize: ${width}x${height}`);
// No actual operation needed in web environment
},
async getWindowSize() {
return { width: window.innerWidth, height: window.innerHeight };
},
async setWindowResizable(resizable) {
console.log("Web mode simulated set window resizable:", resizable);
},
async isWindowResizable() {
return true;
},
async setWindowFullscreen(enable) {
console.log("Web mode simulated fullscreen:", enable);
},
async getMonitorFromCursor() {
return {
size: {
toLogical: (factor: number) => ({
width: window.innerWidth / factor,
height: window.innerHeight / factor,
}),
},
position: {
toLogical: (factor: number) => ({
x: window.screenX / factor,
y: window.screenY / factor,
}),
},
};
},
async centerOnCurrentMonitor() {
// Not applicable in web mode
return;
},
async getWindowPosition() {
return { x: window.screenX, y: window.screenY };
},
async setWindowPosition(x, y) {
console.log(`Web mode simulated set window position: ${x}, ${y}`);
},
async hideWindow() {
console.log("Web mode simulated window hide");
@@ -277,4 +325,4 @@ export const createWebAdapter = (): WebPlatformAdapter => {
return Promise.resolve();
},
};
};
};

View File

@@ -1,5 +1,6 @@
import * as commands from "@/commands";
import { WINDOW_CENTER_BASELINE_HEIGHT } from "@/constants";
import platformAdapter from "../platformAdapter";
// Window operations
export const windowWrapper = {
@@ -10,7 +11,7 @@ export const windowWrapper = {
return getCurrentWebviewWindow();
},
async setSize(width: number, height: number) {
async setLogicalSize(width: number, height: number) {
const { LogicalSize } = await import("@tauri-apps/api/dpi");
const window = await this.getCurrentWebviewWindow();
if (window) {
@@ -20,6 +21,95 @@ export const windowWrapper = {
}
}
},
async getLogicalSize() {
const window = await this.getCurrentWebviewWindow();
if (window) {
const size = await window.innerSize();
const scale = await window.scaleFactor();
return {
width: Math.round(size.width / scale),
height: Math.round(size.height / scale),
};
}
return { width: 0, height: 0 };
},
async setResizable(resizable: boolean) {
const window = await this.getCurrentWebviewWindow();
if (window) {
return window.setResizable(resizable);
}
},
async isResizable() {
const window = await this.getCurrentWebviewWindow();
if (window) {
return window.isResizable();
}
return false;
},
async setFullscreen(enable: boolean) {
const { getCurrentWindow } = await import("@tauri-apps/api/window");
const win = getCurrentWindow();
return win.setFullscreen(enable);
},
async center() {
const window = await this.getCurrentWebviewWindow();
if (window) {
return window.center();
}
},
async setLogicalPosition(x: number, y: number) {
const { LogicalPosition } = await import("@tauri-apps/api/dpi");
const window = await this.getCurrentWebviewWindow();
if (window) {
return window.setPosition(new LogicalPosition(x, y));
}
},
async getLogicalPosition() {
const window = await this.getCurrentWebviewWindow();
if (window) {
const pos = await window.outerPosition();
const scale = await window.scaleFactor();
return { x: Math.round(pos.x / scale), y: Math.round(pos.y / scale) };
}
return { x: 0, y: 0 };
},
async centerOnMonitor() {
const { PhysicalPosition } = await import("@tauri-apps/api/dpi");
const monitor = await platformAdapter.getMonitorFromCursor();
if (!monitor) return;
const window = await this.getCurrentWebviewWindow();
const { x: monitorX, y: monitorY } = monitor.position;
const { width: monitorWidth, height: monitorHeight } = monitor.size;
const windowSize = await window.innerSize();
const x = monitorX + (monitorWidth - windowSize.width) / 2;
const y = monitorY + (monitorHeight - windowSize.height) / 2;
return window.setPosition(new PhysicalPosition(x, y));
},
async isMaximized() {
const window = await this.getCurrentWebviewWindow();
if (window) {
return window.isMaximized();
}
return false;
},
async setMaximized(enable: boolean) {
const window = await this.getCurrentWebviewWindow();
if (window) {
if (enable) {
return window.maximize();
} else {
return window.unmaximize();
}
}
},
};
// Event handling