Compare commits
9 Commits
v0.12.1
...
v0.12.2-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff94c9714e | ||
|
|
429825f434 | ||
|
|
d836d2e62d | ||
|
|
f77fb1562e | ||
|
|
b33421a375 | ||
|
|
c64a9a03f9 | ||
|
|
0d215342e3 | ||
|
|
beb14ea0a2 | ||
|
|
6a4e548d2c |
29
package-lock.json
generated
29
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.12.1",
|
||||
"version": "0.12.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.12.1",
|
||||
"version": "0.12.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -3305,6 +3305,23 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.4.tgz",
|
||||
"integrity": "sha512-EdYd4c9wGvtPB95kqtEyY+bUR+k4kRw3IA30mAQ1jPH6z57AftT8q84qwv0RDp6kkEqOBKxeInKfqi4BESYuqg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-notification": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||
@@ -11985,7 +12002,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.12.1",
|
||||
"version": "0.12.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
@@ -12022,7 +12039,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.1",
|
||||
"version": "0.12.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
@@ -12063,7 +12080,7 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.12.1",
|
||||
"version": "0.12.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
@@ -12071,7 +12088,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.12.1",
|
||||
"version": "0.12.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.12.1",
|
||||
"version": "0.12.2",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||
import fs from "fs"
|
||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||
|
||||
let wakeLockId: number | null = null
|
||||
@@ -65,6 +66,24 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
||||
return { canceled: result.canceled, paths: result.filePaths }
|
||||
})
|
||||
|
||||
ipcMain.handle("filesystem:getDirectoryPaths", async (_event, paths: unknown): Promise<string[]> => {
|
||||
if (!Array.isArray(paths)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const directories = paths.filter((value): value is string => {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return fs.statSync(value).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
return directories
|
||||
})
|
||||
|
||||
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
||||
const next = Boolean(enabled)
|
||||
if (next) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron")
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
||||
|
||||
const electronAPI = {
|
||||
onCliStatus: (callback) => {
|
||||
@@ -12,6 +12,14 @@ const electronAPI = {
|
||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||
getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths),
|
||||
getPathForFile: (file) => {
|
||||
try {
|
||||
return webUtils.getPathForFile(file)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.12.1",
|
||||
"version": "0.12.2",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.1",
|
||||
"version": "0.12.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.1",
|
||||
"version": "0.12.2",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.12.1",
|
||||
"version": "0.12.2",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.12.1",
|
||||
"version": "0.12.2",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -828,14 +828,31 @@ impl CliEntry {
|
||||
|
||||
if dev {
|
||||
// Dev: plain HTTP + Vite dev server proxy.
|
||||
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("ELECTRON_RENDERER_URL")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
})
|
||||
.unwrap_or_else(|| "http://localhost:3000".to_string());
|
||||
let log_level = std::env::var("CLI_LOG_LEVEL")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_lowercase())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "info".to_string());
|
||||
|
||||
args.push("--https".to_string());
|
||||
args.push("false".to_string());
|
||||
args.push("--http".to_string());
|
||||
args.push("true".to_string());
|
||||
args.push("--http-port".to_string());
|
||||
args.push("0".to_string());
|
||||
args.push("--ui-dev-server".to_string());
|
||||
args.push("http://localhost:3000".to_string());
|
||||
args.push(ui_dev_server);
|
||||
args.push("--log-level".to_string());
|
||||
args.push("debug".to_string());
|
||||
args.push(log_level);
|
||||
} else {
|
||||
// Prod desktop: always keep loopback HTTP enabled.
|
||||
args.push("--https".to_string());
|
||||
@@ -900,6 +917,11 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
candidates.push(Some(dir.join("resources/server/dist/bin.js")));
|
||||
candidates.push(Some(dir.join("resources/server/dist/index.js")));
|
||||
candidates.push(Some(dir.join("resources/server/dist/server/bin.js")));
|
||||
candidates.push(Some(dir.join("resources/server/dist/server/index.js")));
|
||||
|
||||
let resources = dir.join("../Resources");
|
||||
candidates.push(Some(resources.join("server/dist/bin.js")));
|
||||
candidates.push(Some(resources.join("server/dist/index.js")));
|
||||
@@ -995,9 +1017,18 @@ fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
||||
}
|
||||
|
||||
fn normalize_path(path: PathBuf) -> String {
|
||||
if let Ok(clean) = path.canonicalize() {
|
||||
clean.to_string_lossy().to_string()
|
||||
let resolved = if let Ok(clean) = path.canonicalize() {
|
||||
clean
|
||||
} else {
|
||||
path.to_string_lossy().to_string()
|
||||
path
|
||||
};
|
||||
|
||||
let rendered = resolved.to_string_lossy().to_string();
|
||||
if let Some(stripped) = rendered.strip_prefix("\\\\?\\UNC\\") {
|
||||
format!("\\\\{}", stripped)
|
||||
} else if let Some(stripped) = rendered.strip_prefix("\\\\?\\") {
|
||||
stripped.to_string()
|
||||
} else {
|
||||
rendered
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
|
||||
Ok(state.manager.status())
|
||||
}
|
||||
|
||||
|
||||
fn is_dev_mode() -> bool {
|
||||
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
||||
}
|
||||
@@ -46,7 +45,10 @@ fn should_allow_internal(url: &Url) -> bool {
|
||||
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
||||
// This must be treated as an internal origin or the navigation guard will
|
||||
// redirect it to the system browser and the app will appear blank.
|
||||
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "tauri.localhost")),
|
||||
"http" | "https" => matches!(
|
||||
url.host_str(),
|
||||
Some("127.0.0.1" | "localhost" | "tauri.localhost")
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -66,6 +68,39 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
||||
paths
|
||||
.iter()
|
||||
.filter_map(|path| match std::fs::metadata(path) {
|
||||
Ok(metadata) if metadata.is_dir() => Some(path.to_string_lossy().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn emit_window_event(app_handle: &AppHandle, window_label: &str, event_name: &str) {
|
||||
if let Some(window) = app_handle.get_webview_window(window_label) {
|
||||
let _ = window.emit(event_name, ());
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_folder_drop_event(
|
||||
app_handle: &AppHandle,
|
||||
window_label: &str,
|
||||
event_name: &str,
|
||||
paths: &[std::path::PathBuf],
|
||||
) {
|
||||
let directories = collect_directory_paths(paths);
|
||||
|
||||
if directories.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(window) = app_handle.get_webview_window(window_label) {
|
||||
let _ = window.emit(event_name, json!({ "paths": directories }));
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||
@@ -187,6 +222,27 @@ fn main() {
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
label,
|
||||
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Enter { paths, .. }),
|
||||
..
|
||||
} => {
|
||||
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drag-enter", &paths);
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
label,
|
||||
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }),
|
||||
..
|
||||
} => {
|
||||
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drop", &paths);
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
label,
|
||||
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Leave),
|
||||
..
|
||||
} => {
|
||||
emit_window_event(&app_handle, &label, "desktop:folder-drag-leave");
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||
..
|
||||
@@ -234,13 +290,16 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
"new_instance",
|
||||
"New Instance",
|
||||
true,
|
||||
Some("CmdOrCtrl+N")
|
||||
Some("CmdOrCtrl+N"),
|
||||
)?;
|
||||
|
||||
|
||||
let file_menu = SubmenuBuilder::new(app, "File")
|
||||
.item(&new_instance_item)
|
||||
.separator()
|
||||
.text(if is_mac { "close" } else { "quit" }, if is_mac { "Close" } else { "Quit" })
|
||||
.text(
|
||||
if is_mac { "close" } else { "quit" },
|
||||
if is_mac { "Close" } else { "Quit" },
|
||||
)
|
||||
.build()?;
|
||||
submenus.push(file_menu);
|
||||
|
||||
@@ -263,7 +322,6 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
.text("force_reload", "Force Reload")
|
||||
.text("toggle_devtools", "Toggle Developer Tools")
|
||||
.separator()
|
||||
|
||||
.separator()
|
||||
.text("toggle_fullscreen", "Toggle Full Screen")
|
||||
.build()?;
|
||||
@@ -277,9 +335,12 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||
submenus.push(window_menu);
|
||||
|
||||
// Build the main menu with all submenus
|
||||
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect();
|
||||
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus
|
||||
.iter()
|
||||
.map(|s| s as &dyn tauri::menu::IsMenuItem<_>)
|
||||
.collect();
|
||||
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
|
||||
|
||||
|
||||
app.set_menu(menu)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.12.1",
|
||||
"version": "0.12.2",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -7,11 +7,13 @@ import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
|
||||
import VersionPill from "./version-pill"
|
||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||
import { githubStars } from "../stores/github-stars"
|
||||
import { formatCompactCount } from "../lib/formatters"
|
||||
import { useI18n, type Locale } from "../lib/i18n"
|
||||
import { showAlertDialog } from "../stores/alerts"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
@@ -193,6 +195,31 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
})
|
||||
})
|
||||
|
||||
function dropTargetBlocked() {
|
||||
return isLoading() || isFolderBrowserOpen() || Boolean(props.advancedSettingsOpen)
|
||||
}
|
||||
|
||||
function showInvalidFolderDropAlert() {
|
||||
showAlertDialog(t("folderSelection.drop.invalidMessage"), {
|
||||
title: t("folderSelection.drop.invalidTitle"),
|
||||
variant: "warning",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const folderDrop = useFolderDrop({
|
||||
enabled: () => !dropTargetBlocked(),
|
||||
onInvalidDrop: showInvalidFolderDropAlert,
|
||||
onDrop: async (paths) => {
|
||||
const firstPath = paths[0]
|
||||
if (!firstPath) {
|
||||
showInvalidFolderDropAlert()
|
||||
return
|
||||
}
|
||||
handleFolderSelect(firstPath)
|
||||
},
|
||||
})
|
||||
|
||||
function formatRelativeTime(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
@@ -317,6 +344,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div
|
||||
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
|
||||
style="background-color: var(--surface-secondary)"
|
||||
onDragEnter={folderDrop.bind.onDragEnter}
|
||||
onDragOver={folderDrop.bind.onDragOver}
|
||||
onDragLeave={folderDrop.bind.onDragLeave}
|
||||
onDrop={folderDrop.bind.onDrop}
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||
@@ -619,6 +650,15 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={folderDrop.isSupported && folderDrop.isActive() && !dropTargetBlocked()}>
|
||||
<div class="folder-drop-overlay" aria-hidden="true">
|
||||
<div class="folder-drop-card">
|
||||
<FolderPlus class="w-8 h-8 icon-muted" />
|
||||
<p class="folder-drop-title">{t("folderSelection.drop.title")}</p>
|
||||
<p class="folder-drop-subtext">{t("folderSelection.drop.subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<AdvancedSettingsModal
|
||||
|
||||
@@ -578,7 +578,6 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
||||
|
||||
const isDeleteMessageHovered = () => {
|
||||
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
|
||||
|
||||
@@ -1290,12 +1289,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||
|
||||
let headerEl: HTMLDivElement | undefined
|
||||
let actionsEl: HTMLDivElement | undefined
|
||||
let primaryEl: HTMLSpanElement | undefined
|
||||
let metaMeasureEl: HTMLSpanElement | undefined
|
||||
const [showMetaInline, setShowMetaInline] = createSignal(true)
|
||||
|
||||
createEffect(() => {
|
||||
setExpanded(Boolean(props.defaultExpanded))
|
||||
})
|
||||
@@ -1323,33 +1316,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
|
||||
const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier()))
|
||||
|
||||
const updateMetaLayout = () => {
|
||||
if (!hasMeta()) return
|
||||
if (!headerEl || !actionsEl || !primaryEl || !metaMeasureEl) return
|
||||
|
||||
const headerWidth = headerEl.getBoundingClientRect().width
|
||||
const actionsWidth = actionsEl.getBoundingClientRect().width
|
||||
const primaryWidth = primaryEl.getBoundingClientRect().width
|
||||
const metaWidth = metaMeasureEl.getBoundingClientRect().width
|
||||
|
||||
const availableLeft = Math.max(0, headerWidth - actionsWidth - 12)
|
||||
setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!hasMeta() || typeof ResizeObserver === "undefined") {
|
||||
setShowMetaInline(true)
|
||||
return
|
||||
}
|
||||
|
||||
updateMetaLayout()
|
||||
const observer = new ResizeObserver(() => updateMetaLayout())
|
||||
if (headerEl) observer.observe(headerEl)
|
||||
if (actionsEl) observer.observe(actionsEl)
|
||||
if (primaryEl) observer.observe(primaryEl)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
const reasoningText = () => {
|
||||
const part = props.part as any
|
||||
if (!part) return ""
|
||||
@@ -1428,7 +1394,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
|
||||
return (
|
||||
<div class="delete-hover-scope message-reasoning-card">
|
||||
<div class="message-reasoning-header" ref={(el) => (headerEl = el)}>
|
||||
<div class="message-reasoning-header">
|
||||
<button
|
||||
type="button"
|
||||
class="message-reasoning-toggle"
|
||||
@@ -1437,7 +1403,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
|
||||
>
|
||||
<span class="message-reasoning-label">
|
||||
<span class="message-reasoning-label-primary" ref={(el) => (primaryEl = el)}>
|
||||
<span class="message-reasoning-label-primary">
|
||||
<Show when={props.showDeleteMessage}>
|
||||
<input
|
||||
class="message-select-checkbox"
|
||||
@@ -1458,43 +1424,10 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
|
||||
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
||||
</span>
|
||||
|
||||
<Show when={hasMeta() && showMetaInline()}>
|
||||
<span class="message-step-meta-inline">
|
||||
<Show when={agentIdentifier()}>
|
||||
{(value) => (
|
||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={modelIdentifier()}>
|
||||
{(value) => (
|
||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
<Show when={hasMeta()}>
|
||||
<span
|
||||
ref={(el) => (metaMeasureEl = el)}
|
||||
class="message-step-meta-inline message-step-meta-inline--measure"
|
||||
>
|
||||
<Show when={agentIdentifier()}>
|
||||
{(value) => (
|
||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={modelIdentifier()}>
|
||||
{(value) => (
|
||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
|
||||
)}
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="message-reasoning-actions" ref={(el) => (actionsEl = el)}>
|
||||
<div class="message-reasoning-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="message-action-button"
|
||||
@@ -1543,7 +1476,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={hasMeta() && !showMetaInline()}>
|
||||
<Show when={hasMeta()}>
|
||||
<div class="message-reasoning-meta-row">
|
||||
<span class="message-step-meta-inline">
|
||||
<Show when={agentIdentifier()}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Index, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js"
|
||||
import VirtualItem from "./virtual-item"
|
||||
import VirtualItem, { type VirtualItemHeightChangeMeta } from "./virtual-item"
|
||||
|
||||
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||
@@ -374,7 +374,14 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
}
|
||||
|
||||
function handleContentRendered() {
|
||||
scheduleAnchorScroll()
|
||||
if (autoScroll() && !anchorLock()) {
|
||||
scheduleAutoPinToBottom()
|
||||
return
|
||||
}
|
||||
if (anchorLock() && !autoScroll()) {
|
||||
scheduleAnchorCorrection()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
@@ -470,9 +477,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
const bottomAfter = rect.bottom
|
||||
const bottomBefore = bottomAfter - delta
|
||||
const wasAboveViewport = bottomBefore < containerRect.top
|
||||
if (!wasAboveViewport) {
|
||||
return
|
||||
}
|
||||
if (!wasAboveViewport) return
|
||||
|
||||
const next = (pendingScrollCompensations.get(key) ?? 0) + delta
|
||||
pendingScrollCompensations.set(key, next)
|
||||
@@ -516,25 +521,51 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
}
|
||||
|
||||
let pendingAutoPin = false
|
||||
let pendingAutoPinFrame: number | null = null
|
||||
|
||||
function clearPendingAutoPinFrame() {
|
||||
if (pendingAutoPinFrame !== null) {
|
||||
cancelAnimationFrame(pendingAutoPinFrame)
|
||||
pendingAutoPinFrame = null
|
||||
}
|
||||
}
|
||||
|
||||
function applyAutoPinToBottom() {
|
||||
if (!containerRef) return false
|
||||
if (!autoScroll()) return false
|
||||
if (anchorLock()) return false
|
||||
|
||||
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
||||
if (containerRef.scrollTop !== maxScrollTop) {
|
||||
containerRef.scrollTop = maxScrollTop
|
||||
lastKnownScrollTop = maxScrollTop
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function scheduleAutoPinToBottom() {
|
||||
if (!containerRef) return
|
||||
if (pendingAutoPin) return
|
||||
pendingAutoPin = true
|
||||
clearPendingAutoPinFrame()
|
||||
const gen = scrollCompensationGen
|
||||
|
||||
// Flush in a microtask so adjustments land before the next paint.
|
||||
// Flush in a microtask so adjustments land before the next paint,
|
||||
// then re-apply on the next two frames to catch deferred layout.
|
||||
queueMicrotask(() => {
|
||||
if (gen !== scrollCompensationGen) return
|
||||
pendingAutoPin = false
|
||||
if (!containerRef) return
|
||||
if (!autoScroll()) return
|
||||
if (anchorLock()) return
|
||||
|
||||
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
||||
if (containerRef.scrollTop !== maxScrollTop) {
|
||||
containerRef.scrollTop = maxScrollTop
|
||||
lastKnownScrollTop = maxScrollTop
|
||||
}
|
||||
if (!applyAutoPinToBottom()) return
|
||||
pendingAutoPinFrame = requestAnimationFrame(() => {
|
||||
pendingAutoPinFrame = null
|
||||
if (gen !== scrollCompensationGen) return
|
||||
if (!applyAutoPinToBottom()) return
|
||||
pendingAutoPinFrame = requestAnimationFrame(() => {
|
||||
pendingAutoPinFrame = null
|
||||
if (gen !== scrollCompensationGen) return
|
||||
applyAutoPinToBottom()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -623,6 +654,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
pendingScrollCompensationScheduled = false
|
||||
pendingScrollCompensations = new Map()
|
||||
pendingAutoPin = false
|
||||
clearPendingAutoPinFrame()
|
||||
|
||||
suppressAutoScrollOnce = false
|
||||
pendingActiveScroll = false
|
||||
@@ -713,7 +745,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
suppressAutoScrollOnce = false
|
||||
return
|
||||
}
|
||||
if (autoScroll()) scheduleAnchorScroll(true)
|
||||
if (autoScroll()) {
|
||||
scheduleAutoPinToBottom()
|
||||
return
|
||||
}
|
||||
if (anchorLock() && !autoScroll()) {
|
||||
scheduleAnchorCorrection()
|
||||
}
|
||||
})
|
||||
|
||||
// Drop anchor lock if the anchored key is removed.
|
||||
@@ -820,6 +858,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
scrollCompensationGen += 1
|
||||
pendingScrollCompensationScheduled = false
|
||||
pendingScrollCompensations = new Map()
|
||||
clearPendingAutoPinFrame()
|
||||
clearScrollToBottomFrames()
|
||||
if (detachScrollIntentListeners) {
|
||||
detachScrollIntentListeners()
|
||||
@@ -883,6 +922,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
const anchorId = () => getAnchorId(key())
|
||||
const overscanPx = props.overscanPx ?? 800
|
||||
const suspendMeasurements = () => measurementsSuspended() || !isActive()
|
||||
const itemVirtualizationEnabled = () => virtualizationEnabled() && !autoScroll()
|
||||
return (
|
||||
<VirtualItem
|
||||
id={anchorId()}
|
||||
@@ -890,9 +930,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
scrollContainer={scrollElement}
|
||||
threshold={overscanPx}
|
||||
placeholderClass="message-stream-placeholder"
|
||||
virtualizationEnabled={virtualizationEnabled}
|
||||
virtualizationEnabled={itemVirtualizationEnabled}
|
||||
suspendMeasurements={suspendMeasurements}
|
||||
onHeightChange={(nextHeight, previousHeight) => {
|
||||
onHeightChange={(nextHeight, previousHeight, meta: VirtualItemHeightChangeMeta) => {
|
||||
const delta = nextHeight - previousHeight
|
||||
|
||||
// Follow mode: keep the viewport pinned to the bottom as
|
||||
@@ -913,12 +953,11 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
// while scrolling upward, compensate scrollTop so visible
|
||||
// content stays stable.
|
||||
if (delta) {
|
||||
if (meta.isStaleCacheCorrection) return
|
||||
scheduleScrollCompensation(key(), delta)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{() => props.renderItem(item(), index)}
|
||||
</VirtualItem>
|
||||
>{() => props.renderItem(item(), index)}</VirtualItem>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
|
||||
@@ -167,10 +167,17 @@ interface VirtualItemProps {
|
||||
forceVisible?: Accessor<boolean>
|
||||
suspendMeasurements?: Accessor<boolean>
|
||||
onMeasured?: () => void
|
||||
onHeightChange?: (nextHeight: number, previousHeight: number) => void
|
||||
onHeightChange?: (nextHeight: number, previousHeight: number, meta: VirtualItemHeightChangeMeta) => void
|
||||
id?: string
|
||||
}
|
||||
|
||||
export interface VirtualItemHeightChangeMeta {
|
||||
source: "initial-visible-measure" | "resize"
|
||||
previousCachedHeight: number | null
|
||||
isStaleCacheCorrection: boolean
|
||||
wasHidden: boolean
|
||||
}
|
||||
|
||||
export default function VirtualItem(props: VirtualItemProps) {
|
||||
const resolveContent = () => (typeof props.children === "function" ? (props.children as () => JSX.Element)() : props.children)
|
||||
const cachedHeight = sizeCache.get(props.cacheKey)
|
||||
@@ -183,10 +190,11 @@ export default function VirtualItem(props: VirtualItemProps) {
|
||||
// When content first mounts, onHeightChange deltas should reflect the DOM's
|
||||
// placeholder height (not 0), otherwise scroll compensation can overshoot.
|
||||
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? fallbackPlaceholderHeight())
|
||||
const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined)
|
||||
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
|
||||
let pendingVisibility: boolean | null = null
|
||||
let visibilityFrame: number | null = null
|
||||
let awaitingVisibleMeasurement = true
|
||||
let lastMeasurementWhileHidden = true
|
||||
const flushVisibility = () => {
|
||||
if (visibilityFrame !== null) {
|
||||
cancelAnimationFrame(visibilityFrame)
|
||||
@@ -210,14 +218,14 @@ export default function VirtualItem(props: VirtualItemProps) {
|
||||
}
|
||||
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
||||
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
||||
const forceVisible = () => Boolean(props.forceVisible?.())
|
||||
const shouldHideContent = createMemo(() => {
|
||||
if (props.forceVisible?.()) return false
|
||||
if (forceVisible()) return false
|
||||
if (!virtualizationEnabled()) return false
|
||||
return !isIntersecting()
|
||||
})
|
||||
|
||||
let wrapperRef: HTMLDivElement | undefined
|
||||
|
||||
|
||||
let wrapperRef: HTMLDivElement | undefined
|
||||
let contentRef: HTMLDivElement | undefined
|
||||
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
@@ -230,6 +238,17 @@ export default function VirtualItem(props: VirtualItemProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleVisibleMeasurements() {
|
||||
if (shouldHideContent() || measurementsSuspended()) return
|
||||
if (!contentRef) return
|
||||
queueMicrotask(() => {
|
||||
if (shouldHideContent() || measurementsSuspended()) return
|
||||
if (!contentRef) return
|
||||
updateMeasuredHeight()
|
||||
setupResizeObserver()
|
||||
})
|
||||
}
|
||||
|
||||
function cleanupIntersectionObserver() {
|
||||
if (intersectionCleanup) {
|
||||
intersectionCleanup()
|
||||
@@ -237,13 +256,24 @@ export default function VirtualItem(props: VirtualItemProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function persistMeasurement(nextHeight: number) {
|
||||
function persistMeasurement(nextHeight: number, meta?: { source: "initial-visible-measure" | "resize"; wasHidden: boolean }) {
|
||||
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
|
||||
return
|
||||
}
|
||||
const before = measuredHeight()
|
||||
const normalized = nextHeight
|
||||
const previous = sizeCache.get(props.cacheKey) ?? measuredHeight()
|
||||
const previousCachedHeight = sizeCache.get(props.cacheKey) ?? null
|
||||
const previous = previousCachedHeight ?? measuredHeight()
|
||||
const measurementMeta: VirtualItemHeightChangeMeta = {
|
||||
source: meta?.source ?? "resize",
|
||||
previousCachedHeight,
|
||||
isStaleCacheCorrection:
|
||||
(meta?.source ?? "resize") === "initial-visible-measure" &&
|
||||
previousCachedHeight !== null &&
|
||||
normalized > 0 &&
|
||||
Math.abs(normalized - previousCachedHeight) > 1,
|
||||
wasHidden: meta?.wasHidden ?? shouldHideContent(),
|
||||
}
|
||||
// Only keep the previous measurement when the element reports 0 height.
|
||||
// Allow shrinkage so placeholder height matches real content height;
|
||||
// keeping the max height can cause mount/unmount jitter near the
|
||||
@@ -254,34 +284,40 @@ export default function VirtualItem(props: VirtualItemProps) {
|
||||
hasReportedMeasurement = true
|
||||
props.onMeasured?.()
|
||||
}
|
||||
setHasMeasured(true)
|
||||
sizeCache.set(props.cacheKey, previous)
|
||||
setMeasuredHeight(previous)
|
||||
if (previous !== before) props.onHeightChange?.(previous, before)
|
||||
if (previous !== before) props.onHeightChange?.(previous, before, measurementMeta)
|
||||
return
|
||||
}
|
||||
if (normalized > 0) {
|
||||
sizeCache.set(props.cacheKey, normalized)
|
||||
setHasMeasured(true)
|
||||
if (!hasReportedMeasurement) {
|
||||
hasReportedMeasurement = true
|
||||
props.onMeasured?.()
|
||||
}
|
||||
}
|
||||
setMeasuredHeight(normalized)
|
||||
if (normalized !== before) props.onHeightChange?.(normalized, before)
|
||||
if (normalized !== before) props.onHeightChange?.(normalized, before, measurementMeta)
|
||||
}
|
||||
|
||||
function updateMeasuredHeight() {
|
||||
if (!contentRef || measurementsSuspended()) return
|
||||
if (!contentRef) return
|
||||
if (measurementsSuspended()) return
|
||||
// Prefer subpixel-accurate height for scroll compensation.
|
||||
// offsetHeight rounds to integers which can accumulate error.
|
||||
const rect = contentRef.getBoundingClientRect()
|
||||
const next = Math.max(0, Math.round(rect.height * 2) / 2)
|
||||
if (next === measuredHeight()) return
|
||||
persistMeasurement(next)
|
||||
const currentMeasured = measuredHeight()
|
||||
const measurementSource: "initial-visible-measure" | "resize" = awaitingVisibleMeasurement ? "initial-visible-measure" : "resize"
|
||||
const wasHidden = lastMeasurementWhileHidden
|
||||
if (measurementSource === "initial-visible-measure") {
|
||||
awaitingVisibleMeasurement = false
|
||||
lastMeasurementWhileHidden = false
|
||||
}
|
||||
if (next === currentMeasured) return
|
||||
persistMeasurement(next, { source: measurementSource, wasHidden })
|
||||
}
|
||||
|
||||
|
||||
function setupResizeObserver() {
|
||||
if (!contentRef || measurementsSuspended()) return
|
||||
cleanupResizeObserver()
|
||||
@@ -377,30 +413,29 @@ export default function VirtualItem(props: VirtualItemProps) {
|
||||
cleanupResizeObserver()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
if (shouldHideContent() || measurementsSuspended()) {
|
||||
const hidden = shouldHideContent()
|
||||
if (hidden) {
|
||||
awaitingVisibleMeasurement = true
|
||||
lastMeasurementWhileHidden = true
|
||||
}
|
||||
if (hidden || measurementsSuspended()) {
|
||||
cleanupResizeObserver()
|
||||
} else if (contentRef) {
|
||||
queueMicrotask(() => {
|
||||
updateMeasuredHeight()
|
||||
setupResizeObserver()
|
||||
})
|
||||
}
|
||||
if (!hidden && !measurementsSuspended() && contentRef) {
|
||||
scheduleVisibleMeasurements()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
|
||||
createEffect(() => {
|
||||
const key = props.cacheKey
|
||||
|
||||
const cached = sizeCache.get(key)
|
||||
if (cached !== undefined) {
|
||||
setMeasuredHeight(cached)
|
||||
setHasMeasured(true)
|
||||
} else {
|
||||
setMeasuredHeight(fallbackPlaceholderHeight())
|
||||
setHasMeasured(false)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -418,7 +453,7 @@ export default function VirtualItem(props: VirtualItemProps) {
|
||||
}
|
||||
return props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
|
||||
})
|
||||
|
||||
|
||||
onCleanup(() => {
|
||||
cleanupResizeObserver()
|
||||
cleanupIntersectionObserver()
|
||||
|
||||
158
packages/ui/src/lib/hooks/use-folder-drop.ts
Normal file
158
packages/ui/src/lib/hooks/use-folder-drop.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Accessor, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import {
|
||||
containsFileDrop,
|
||||
extractDroppedDirectoryPaths,
|
||||
listenForNativeFolderDrops,
|
||||
listenForNativeFolderDropState,
|
||||
normalizeDroppedDirectoryPaths,
|
||||
supportsDesktopFolderDrop,
|
||||
} from "../native/desktop-file-drop"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
|
||||
interface UseFolderDropOptions {
|
||||
enabled: Accessor<boolean>
|
||||
onDrop: (paths: string[]) => void | Promise<void>
|
||||
onInvalidDrop?: () => void
|
||||
}
|
||||
|
||||
interface FolderDropBindings {
|
||||
onDragEnter: (event: DragEvent) => void
|
||||
onDragOver: (event: DragEvent) => void
|
||||
onDragLeave: (event: DragEvent) => void
|
||||
onDrop: (event: DragEvent) => void
|
||||
}
|
||||
|
||||
export function useFolderDrop(options: UseFolderDropOptions): {
|
||||
isActive: Accessor<boolean>
|
||||
isSupported: boolean
|
||||
bind: FolderDropBindings
|
||||
} {
|
||||
const [isActive, setIsActive] = createSignal(false)
|
||||
const [dragDepth, setDragDepth] = createSignal(0)
|
||||
const isSupported = supportsDesktopFolderDrop()
|
||||
|
||||
function reset() {
|
||||
setDragDepth(0)
|
||||
setIsActive(false)
|
||||
}
|
||||
|
||||
async function handleResolvedPaths(paths: string[]) {
|
||||
reset()
|
||||
if (!options.enabled()) {
|
||||
return
|
||||
}
|
||||
const directoryPaths = await normalizeDroppedDirectoryPaths(paths)
|
||||
if (directoryPaths.length === 0) {
|
||||
options.onInvalidDrop?.()
|
||||
return
|
||||
}
|
||||
await options.onDrop(directoryPaths)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!options.enabled()) {
|
||||
reset()
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (!isSupported) {
|
||||
return
|
||||
}
|
||||
|
||||
let disposeNativeDrop = () => {}
|
||||
let disposeNativeState = () => {}
|
||||
|
||||
void listenForNativeFolderDrops((paths) => {
|
||||
if (!options.enabled()) {
|
||||
return
|
||||
}
|
||||
void handleResolvedPaths(paths)
|
||||
}).then((dispose) => {
|
||||
disposeNativeDrop = dispose
|
||||
})
|
||||
|
||||
void listenForNativeFolderDropState((state) => {
|
||||
if (!options.enabled()) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
if (state === "enter") {
|
||||
setIsActive(true)
|
||||
return
|
||||
}
|
||||
reset()
|
||||
}).then((dispose) => {
|
||||
disposeNativeState = dispose
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
disposeNativeDrop()
|
||||
disposeNativeState()
|
||||
})
|
||||
})
|
||||
|
||||
const bind: FolderDropBindings = {
|
||||
onDragEnter(event) {
|
||||
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
setDragDepth((prev) => prev + 1)
|
||||
setIsActive(true)
|
||||
},
|
||||
onDragOver(event) {
|
||||
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = "copy"
|
||||
}
|
||||
setIsActive(true)
|
||||
},
|
||||
onDragLeave(event) {
|
||||
if (!isSupported || runtimeEnv.host === "tauri" || !containsFileDrop(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
const nextDepth = Math.max(0, dragDepth() - 1)
|
||||
setDragDepth(nextDepth)
|
||||
if (nextDepth === 0) {
|
||||
setIsActive(false)
|
||||
}
|
||||
},
|
||||
onDrop(event) {
|
||||
if (!isSupported) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
if (!options.enabled()) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
const paths = extractDroppedDirectoryPaths(event)
|
||||
if (paths.length === 0) {
|
||||
reset()
|
||||
options.onInvalidDrop?.()
|
||||
return
|
||||
}
|
||||
|
||||
void handleResolvedPaths(paths)
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
isActive,
|
||||
isSupported,
|
||||
bind,
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,11 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.loading.title": "Starting instance...",
|
||||
"folderSelection.loading.subtitle": "Hang tight while we prepare your workspace.",
|
||||
|
||||
"folderSelection.drop.title": "Drop a folder to open it",
|
||||
"folderSelection.drop.subtitle": "Start a new instance in the dropped folder.",
|
||||
"folderSelection.drop.invalidTitle": "Couldn't open dropped item",
|
||||
"folderSelection.drop.invalidMessage": "Drop a folder to start a new instance.",
|
||||
|
||||
"folderSelection.dialog.title": "Select Workspace",
|
||||
"folderSelection.dialog.description": "Select workspace to start coding.",
|
||||
} as const
|
||||
|
||||
@@ -31,6 +31,11 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.loading.title": "Iniciando instancia...",
|
||||
"folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.",
|
||||
|
||||
"folderSelection.drop.title": "Suelta una carpeta para abrirla",
|
||||
"folderSelection.drop.subtitle": "Inicia una nueva instancia en la carpeta soltada.",
|
||||
"folderSelection.drop.invalidTitle": "No se pudo abrir el elemento soltado",
|
||||
"folderSelection.drop.invalidMessage": "Suelta una carpeta para iniciar una nueva instancia.",
|
||||
|
||||
"folderSelection.dialog.title": "Seleccionar workspace",
|
||||
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
|
||||
} as const
|
||||
|
||||
@@ -31,6 +31,11 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.loading.title": "Démarrage de l'instance...",
|
||||
"folderSelection.loading.subtitle": "Patientez pendant que nous préparons votre espace de travail.",
|
||||
|
||||
"folderSelection.drop.title": "Déposez un dossier pour l'ouvrir",
|
||||
"folderSelection.drop.subtitle": "Démarrez une nouvelle instance dans le dossier déposé.",
|
||||
"folderSelection.drop.invalidTitle": "Impossible d'ouvrir l'élément déposé",
|
||||
"folderSelection.drop.invalidMessage": "Déposez un dossier pour démarrer une nouvelle instance.",
|
||||
|
||||
"folderSelection.dialog.title": "Sélectionner l'espace de travail",
|
||||
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
|
||||
} as const
|
||||
|
||||
@@ -31,6 +31,11 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.loading.title": "インスタンスを起動中...",
|
||||
"folderSelection.loading.subtitle": "ワークスペースを準備しています。しばらくお待ちください。",
|
||||
|
||||
"folderSelection.drop.title": "フォルダをドロップして開く",
|
||||
"folderSelection.drop.subtitle": "ドロップしたフォルダで新しいインスタンスを開始します。",
|
||||
"folderSelection.drop.invalidTitle": "ドロップした項目を開けませんでした",
|
||||
"folderSelection.drop.invalidMessage": "新しいインスタンスを開始するにはフォルダをドロップしてください。",
|
||||
|
||||
"folderSelection.dialog.title": "ワークスペースを選択",
|
||||
"folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。",
|
||||
} as const
|
||||
|
||||
@@ -31,6 +31,11 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.loading.title": "Запуск экземпляра…",
|
||||
"folderSelection.loading.subtitle": "Подождите, пока мы подготовим рабочее пространство.",
|
||||
|
||||
"folderSelection.drop.title": "Перетащите папку, чтобы открыть ее",
|
||||
"folderSelection.drop.subtitle": "Запустите новый экземпляр в перетащенной папке.",
|
||||
"folderSelection.drop.invalidTitle": "Не удалось открыть перетащенный элемент",
|
||||
"folderSelection.drop.invalidMessage": "Перетащите папку, чтобы запустить новый экземпляр.",
|
||||
|
||||
"folderSelection.dialog.title": "Выберите рабочее пространство",
|
||||
"folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.",
|
||||
} as const
|
||||
|
||||
@@ -31,6 +31,11 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.loading.title": "正在启动实例...",
|
||||
"folderSelection.loading.subtitle": "正在准备你的工作区,请稍候。",
|
||||
|
||||
"folderSelection.drop.title": "拖放文件夹以打开",
|
||||
"folderSelection.drop.subtitle": "在拖放的文件夹中启动一个新实例。",
|
||||
"folderSelection.drop.invalidTitle": "无法打开拖放的项目",
|
||||
"folderSelection.drop.invalidMessage": "请拖放一个文件夹来启动新实例。",
|
||||
|
||||
"folderSelection.dialog.title": "选择工作区",
|
||||
"folderSelection.dialog.description": "选择工作区以开始编码。",
|
||||
} as const
|
||||
|
||||
155
packages/ui/src/lib/native/desktop-file-drop.ts
Normal file
155
packages/ui/src/lib/native/desktop-file-drop.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { getLogger } from "../logger"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
type NativeFolderDropState = "enter" | "leave"
|
||||
|
||||
interface TauriFolderDropPayload {
|
||||
paths?: unknown
|
||||
}
|
||||
|
||||
function normalizePathList(input: unknown): string[] {
|
||||
if (!Array.isArray(input)) {
|
||||
return []
|
||||
}
|
||||
return input.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
}
|
||||
|
||||
function getFilePath(file: File): string | null {
|
||||
if (typeof file.path === "string" && file.path.trim().length > 0) {
|
||||
return file.path
|
||||
}
|
||||
if (runtimeEnv.host === "electron") {
|
||||
const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file)
|
||||
if (typeof electronPath === "string" && electronPath.trim().length > 0) {
|
||||
return electronPath
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveElectronDirectoryPaths(paths: string[]): Promise<string[]> {
|
||||
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
|
||||
if (!api?.getDirectoryPaths || paths.length === 0) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
return await api.getDirectoryPaths(paths)
|
||||
} catch (error) {
|
||||
log.error("[native] failed to validate dropped directory paths", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function supportsDesktopFolderDrop(): boolean {
|
||||
return runtimeEnv.platform === "desktop" && runtimeEnv.host !== "web"
|
||||
}
|
||||
|
||||
export function containsFileDrop(event: DragEvent): boolean {
|
||||
const types = event.dataTransfer?.types
|
||||
if (!types) {
|
||||
return false
|
||||
}
|
||||
return Array.from(types).includes("Files")
|
||||
}
|
||||
|
||||
export function extractDroppedDirectoryPaths(event: DragEvent): string[] {
|
||||
const dataTransfer = event.dataTransfer
|
||||
if (!dataTransfer) {
|
||||
return []
|
||||
}
|
||||
|
||||
const directoryHints = new Set<string>()
|
||||
for (const item of Array.from(dataTransfer.items ?? [])) {
|
||||
if (item.kind !== "file") {
|
||||
continue
|
||||
}
|
||||
const entry = item.webkitGetAsEntry?.()
|
||||
if (!entry?.isDirectory) {
|
||||
continue
|
||||
}
|
||||
const file = item.getAsFile()
|
||||
const filePath = file ? getFilePath(file) : null
|
||||
if (filePath) {
|
||||
directoryHints.add(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
const paths = new Set<string>()
|
||||
for (const file of Array.from(dataTransfer.files ?? [])) {
|
||||
const filePath = getFilePath(file)
|
||||
if (!filePath) {
|
||||
continue
|
||||
}
|
||||
if (directoryHints.size > 0 && !directoryHints.has(filePath)) {
|
||||
continue
|
||||
}
|
||||
paths.add(filePath)
|
||||
}
|
||||
|
||||
return Array.from(paths)
|
||||
}
|
||||
|
||||
export async function normalizeDroppedDirectoryPaths(paths: string[]): Promise<string[]> {
|
||||
const uniquePaths = Array.from(new Set(paths.filter((path) => typeof path === "string" && path.trim().length > 0)))
|
||||
if (uniquePaths.length === 0) {
|
||||
return []
|
||||
}
|
||||
if (runtimeEnv.host === "electron") {
|
||||
return resolveElectronDirectoryPaths(uniquePaths)
|
||||
}
|
||||
return uniquePaths
|
||||
}
|
||||
|
||||
export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => void): Promise<() => void> {
|
||||
if (runtimeEnv.host !== "tauri") {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const eventApi = window.__TAURI__?.event
|
||||
if (!eventApi?.listen) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
try {
|
||||
const unlisten = await eventApi.listen("desktop:folder-drop", (event) => {
|
||||
const payload = (event.payload ?? {}) as TauriFolderDropPayload
|
||||
const paths = normalizePathList(payload.paths)
|
||||
if (paths.length > 0) {
|
||||
onDrop(paths)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
unlisten()
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("[native] failed to listen for folder-drop event", error)
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> {
|
||||
if (runtimeEnv.host !== "tauri") {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
const eventApi = window.__TAURI__?.event
|
||||
if (!eventApi?.listen) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
try {
|
||||
const [unlistenEnter, unlistenLeave] = await Promise.all([
|
||||
eventApi.listen("desktop:folder-drag-enter", () => onState("enter")),
|
||||
eventApi.listen("desktop:folder-drag-leave", () => onState("leave")),
|
||||
])
|
||||
return () => {
|
||||
unlistenEnter()
|
||||
unlistenLeave()
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("[native] failed to listen for folder-drop state", error)
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
39
packages/ui/src/styles/components/folder-drop.css
Normal file
39
packages/ui/src/styles/components/folder-drop.css
Normal file
@@ -0,0 +1,39 @@
|
||||
.folder-drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
background: color-mix(in srgb, var(--folder-overlay-bg) 88%, var(--accent-primary) 12%);
|
||||
backdrop-filter: blur(3px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.folder-drop-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
width: min(560px, 100%);
|
||||
padding: 2rem;
|
||||
border: 2px dashed var(--accent-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
background-color: color-mix(in srgb, var(--surface-base) 92%, var(--accent-primary) 8%);
|
||||
box-shadow: var(--folder-card-shadow);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.folder-drop-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.folder-drop-subtext {
|
||||
max-width: 32rem;
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-relaxed);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@import "./components/buttons.css";
|
||||
@import "./components/badges.css";
|
||||
@import "./components/folder-drop.css";
|
||||
@import "./components/folder-loading.css";
|
||||
@import "./components/dropdown.css";
|
||||
@import "./components/selector.css";
|
||||
|
||||
18
packages/ui/src/types/global.d.ts
vendored
18
packages/ui/src/types/global.d.ts
vendored
@@ -27,11 +27,26 @@ declare global {
|
||||
getCliStatus?: () => Promise<unknown>
|
||||
restartCli?: () => Promise<unknown>
|
||||
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
||||
getDirectoryPaths?: (paths: string[]) => Promise<string[]>
|
||||
getPathForFile?: (file: File) => string | null
|
||||
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
|
||||
|
||||
showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }>
|
||||
}
|
||||
|
||||
interface File {
|
||||
path?: string
|
||||
}
|
||||
|
||||
interface FileSystemEntry {
|
||||
isDirectory: boolean
|
||||
isFile: boolean
|
||||
}
|
||||
|
||||
interface DataTransferItem {
|
||||
webkitGetAsEntry?: () => FileSystemEntry | null
|
||||
}
|
||||
|
||||
interface TauriDialogModule {
|
||||
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
|
||||
save?: (options: Record<string, unknown>) => Promise<string | null>
|
||||
@@ -40,6 +55,9 @@ declare global {
|
||||
interface TauriBridge {
|
||||
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
||||
dialog?: TauriDialogModule
|
||||
event?: {
|
||||
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
|
||||
}
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
Reference in New Issue
Block a user