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",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
@@ -3305,6 +3305,23 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@tauri-apps/plugin-notification": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||||
@@ -11985,7 +12002,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
@@ -12022,7 +12039,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
@@ -12063,7 +12080,7 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
@@ -12071,7 +12088,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||||
|
import fs from "fs"
|
||||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||||
|
|
||||||
let wakeLockId: number | null = null
|
let wakeLockId: number | null = null
|
||||||
@@ -65,6 +66,24 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
return { canceled: result.canceled, paths: result.filePaths }
|
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 }> => {
|
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
||||||
const next = Boolean(enabled)
|
const next = Boolean(enabled)
|
||||||
if (next) {
|
if (next) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { contextBridge, ipcRenderer } = require("electron")
|
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
||||||
|
|
||||||
const electronAPI = {
|
const electronAPI = {
|
||||||
onCliStatus: (callback) => {
|
onCliStatus: (callback) => {
|
||||||
@@ -12,6 +12,14 @@ const electronAPI = {
|
|||||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
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)),
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -828,14 +828,31 @@ impl CliEntry {
|
|||||||
|
|
||||||
if dev {
|
if dev {
|
||||||
// Dev: plain HTTP + Vite dev server proxy.
|
// 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("--https".to_string());
|
||||||
args.push("false".to_string());
|
args.push("false".to_string());
|
||||||
args.push("--http".to_string());
|
args.push("--http".to_string());
|
||||||
args.push("true".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("--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("--log-level".to_string());
|
||||||
args.push("debug".to_string());
|
args.push(log_level);
|
||||||
} else {
|
} else {
|
||||||
// Prod desktop: always keep loopback HTTP enabled.
|
// Prod desktop: always keep loopback HTTP enabled.
|
||||||
args.push("--https".to_string());
|
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 Ok(exe) = std::env::current_exe() {
|
||||||
if let Some(dir) = exe.parent() {
|
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");
|
let resources = dir.join("../Resources");
|
||||||
candidates.push(Some(resources.join("server/dist/bin.js")));
|
candidates.push(Some(resources.join("server/dist/bin.js")));
|
||||||
candidates.push(Some(resources.join("server/dist/index.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 {
|
fn normalize_path(path: PathBuf) -> String {
|
||||||
if let Ok(clean) = path.canonicalize() {
|
let resolved = if let Ok(clean) = path.canonicalize() {
|
||||||
clean.to_string_lossy().to_string()
|
clean
|
||||||
} else {
|
} 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())
|
Ok(state.manager.status())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn is_dev_mode() -> bool {
|
fn is_dev_mode() -> bool {
|
||||||
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
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`.
|
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
||||||
// This must be treated as an internal origin or the navigation guard will
|
// 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.
|
// 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,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,6 +68,39 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
|||||||
false
|
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() {
|
fn main() {
|
||||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||||
@@ -187,6 +222,27 @@ fn main() {
|
|||||||
app.exit(0);
|
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 {
|
tauri::RunEvent::WindowEvent {
|
||||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||||
..
|
..
|
||||||
@@ -234,13 +290,16 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
"new_instance",
|
"new_instance",
|
||||||
"New Instance",
|
"New Instance",
|
||||||
true,
|
true,
|
||||||
Some("CmdOrCtrl+N")
|
Some("CmdOrCtrl+N"),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let file_menu = SubmenuBuilder::new(app, "File")
|
let file_menu = SubmenuBuilder::new(app, "File")
|
||||||
.item(&new_instance_item)
|
.item(&new_instance_item)
|
||||||
.separator()
|
.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()?;
|
.build()?;
|
||||||
submenus.push(file_menu);
|
submenus.push(file_menu);
|
||||||
|
|
||||||
@@ -263,7 +322,6 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
.text("force_reload", "Force Reload")
|
.text("force_reload", "Force Reload")
|
||||||
.text("toggle_devtools", "Toggle Developer Tools")
|
.text("toggle_devtools", "Toggle Developer Tools")
|
||||||
.separator()
|
.separator()
|
||||||
|
|
||||||
.separator()
|
.separator()
|
||||||
.text("toggle_fullscreen", "Toggle Full Screen")
|
.text("toggle_fullscreen", "Toggle Full Screen")
|
||||||
.build()?;
|
.build()?;
|
||||||
@@ -277,7 +335,10 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
submenus.push(window_menu);
|
submenus.push(window_menu);
|
||||||
|
|
||||||
// Build the main menu with all submenus
|
// 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()?;
|
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
|
||||||
|
|
||||||
app.set_menu(menu)?;
|
app.set_menu(menu)?;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.12.1",
|
"version": "0.12.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import DirectoryBrowserDialog from "./directory-browser-dialog"
|
|||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
import { ThemeModeToggle } from "./theme-mode-toggle"
|
||||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||||
|
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
|
||||||
import VersionPill from "./version-pill"
|
import VersionPill from "./version-pill"
|
||||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||||
import { githubStars } from "../stores/github-stars"
|
import { githubStars } from "../stores/github-stars"
|
||||||
import { formatCompactCount } from "../lib/formatters"
|
import { formatCompactCount } from "../lib/formatters"
|
||||||
import { useI18n, type Locale } from "../lib/i18n"
|
import { useI18n, type Locale } from "../lib/i18n"
|
||||||
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
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 {
|
function formatRelativeTime(timestamp: number): string {
|
||||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||||
const minutes = Math.floor(seconds / 60)
|
const minutes = Math.floor(seconds / 60)
|
||||||
@@ -317,6 +344,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div
|
<div
|
||||||
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
|
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)"
|
style="background-color: var(--surface-secondary)"
|
||||||
|
onDragEnter={folderDrop.bind.onDragEnter}
|
||||||
|
onDragOver={folderDrop.bind.onDragOver}
|
||||||
|
onDragLeave={folderDrop.bind.onDragLeave}
|
||||||
|
onDrop={folderDrop.bind.onDrop}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
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>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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>
|
</div>
|
||||||
|
|
||||||
<AdvancedSettingsModal
|
<AdvancedSettingsModal
|
||||||
|
|||||||
@@ -578,7 +578,6 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||||
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
||||||
|
|
||||||
const isDeleteMessageHovered = () => {
|
const isDeleteMessageHovered = () => {
|
||||||
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
|
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
|
||||||
|
|
||||||
@@ -1290,12 +1289,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
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(() => {
|
createEffect(() => {
|
||||||
setExpanded(Boolean(props.defaultExpanded))
|
setExpanded(Boolean(props.defaultExpanded))
|
||||||
})
|
})
|
||||||
@@ -1323,33 +1316,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier()))
|
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 reasoningText = () => {
|
||||||
const part = props.part as any
|
const part = props.part as any
|
||||||
if (!part) return ""
|
if (!part) return ""
|
||||||
@@ -1428,7 +1394,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="delete-hover-scope message-reasoning-card">
|
<div class="delete-hover-scope message-reasoning-card">
|
||||||
<div class="message-reasoning-header" ref={(el) => (headerEl = el)}>
|
<div class="message-reasoning-header">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="message-reasoning-toggle"
|
class="message-reasoning-toggle"
|
||||||
@@ -1437,7 +1403,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
|
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
|
||||||
>
|
>
|
||||||
<span class="message-reasoning-label">
|
<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}>
|
<Show when={props.showDeleteMessage}>
|
||||||
<input
|
<input
|
||||||
class="message-select-checkbox"
|
class="message-select-checkbox"
|
||||||
@@ -1458,43 +1424,10 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
||||||
</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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="message-reasoning-actions" ref={(el) => (actionsEl = el)}>
|
<div class="message-reasoning-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
@@ -1543,7 +1476,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={hasMeta() && !showMetaInline()}>
|
<Show when={hasMeta()}>
|
||||||
<div class="message-reasoning-meta-row">
|
<div class="message-reasoning-meta-row">
|
||||||
<span class="message-step-meta-inline">
|
<span class="message-step-meta-inline">
|
||||||
<Show when={agentIdentifier()}>
|
<Show when={agentIdentifier()}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Index, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js"
|
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 DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||||
@@ -374,7 +374,14 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleContentRendered() {
|
function handleContentRendered() {
|
||||||
scheduleAnchorScroll()
|
if (autoScroll() && !anchorLock()) {
|
||||||
|
scheduleAutoPinToBottom()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (anchorLock() && !autoScroll()) {
|
||||||
|
scheduleAnchorCorrection()
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
@@ -470,9 +477,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
const bottomAfter = rect.bottom
|
const bottomAfter = rect.bottom
|
||||||
const bottomBefore = bottomAfter - delta
|
const bottomBefore = bottomAfter - delta
|
||||||
const wasAboveViewport = bottomBefore < containerRect.top
|
const wasAboveViewport = bottomBefore < containerRect.top
|
||||||
if (!wasAboveViewport) {
|
if (!wasAboveViewport) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = (pendingScrollCompensations.get(key) ?? 0) + delta
|
const next = (pendingScrollCompensations.get(key) ?? 0) + delta
|
||||||
pendingScrollCompensations.set(key, next)
|
pendingScrollCompensations.set(key, next)
|
||||||
@@ -516,25 +521,51 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let pendingAutoPin = false
|
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() {
|
function scheduleAutoPinToBottom() {
|
||||||
if (!containerRef) return
|
if (!containerRef) return
|
||||||
if (pendingAutoPin) return
|
if (pendingAutoPin) return
|
||||||
pendingAutoPin = true
|
pendingAutoPin = true
|
||||||
|
clearPendingAutoPinFrame()
|
||||||
const gen = scrollCompensationGen
|
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(() => {
|
queueMicrotask(() => {
|
||||||
if (gen !== scrollCompensationGen) return
|
if (gen !== scrollCompensationGen) return
|
||||||
pendingAutoPin = false
|
pendingAutoPin = false
|
||||||
if (!containerRef) return
|
if (!applyAutoPinToBottom()) return
|
||||||
if (!autoScroll()) return
|
pendingAutoPinFrame = requestAnimationFrame(() => {
|
||||||
if (anchorLock()) return
|
pendingAutoPinFrame = null
|
||||||
|
if (gen !== scrollCompensationGen) return
|
||||||
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
if (!applyAutoPinToBottom()) return
|
||||||
if (containerRef.scrollTop !== maxScrollTop) {
|
pendingAutoPinFrame = requestAnimationFrame(() => {
|
||||||
containerRef.scrollTop = maxScrollTop
|
pendingAutoPinFrame = null
|
||||||
lastKnownScrollTop = maxScrollTop
|
if (gen !== scrollCompensationGen) return
|
||||||
}
|
applyAutoPinToBottom()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,6 +654,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
pendingScrollCompensationScheduled = false
|
pendingScrollCompensationScheduled = false
|
||||||
pendingScrollCompensations = new Map()
|
pendingScrollCompensations = new Map()
|
||||||
pendingAutoPin = false
|
pendingAutoPin = false
|
||||||
|
clearPendingAutoPinFrame()
|
||||||
|
|
||||||
suppressAutoScrollOnce = false
|
suppressAutoScrollOnce = false
|
||||||
pendingActiveScroll = false
|
pendingActiveScroll = false
|
||||||
@@ -713,7 +745,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
suppressAutoScrollOnce = false
|
suppressAutoScrollOnce = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (autoScroll()) scheduleAnchorScroll(true)
|
if (autoScroll()) {
|
||||||
|
scheduleAutoPinToBottom()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (anchorLock() && !autoScroll()) {
|
||||||
|
scheduleAnchorCorrection()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Drop anchor lock if the anchored key is removed.
|
// Drop anchor lock if the anchored key is removed.
|
||||||
@@ -820,6 +858,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
scrollCompensationGen += 1
|
scrollCompensationGen += 1
|
||||||
pendingScrollCompensationScheduled = false
|
pendingScrollCompensationScheduled = false
|
||||||
pendingScrollCompensations = new Map()
|
pendingScrollCompensations = new Map()
|
||||||
|
clearPendingAutoPinFrame()
|
||||||
clearScrollToBottomFrames()
|
clearScrollToBottomFrames()
|
||||||
if (detachScrollIntentListeners) {
|
if (detachScrollIntentListeners) {
|
||||||
detachScrollIntentListeners()
|
detachScrollIntentListeners()
|
||||||
@@ -883,6 +922,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
const anchorId = () => getAnchorId(key())
|
const anchorId = () => getAnchorId(key())
|
||||||
const overscanPx = props.overscanPx ?? 800
|
const overscanPx = props.overscanPx ?? 800
|
||||||
const suspendMeasurements = () => measurementsSuspended() || !isActive()
|
const suspendMeasurements = () => measurementsSuspended() || !isActive()
|
||||||
|
const itemVirtualizationEnabled = () => virtualizationEnabled() && !autoScroll()
|
||||||
return (
|
return (
|
||||||
<VirtualItem
|
<VirtualItem
|
||||||
id={anchorId()}
|
id={anchorId()}
|
||||||
@@ -890,9 +930,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
scrollContainer={scrollElement}
|
scrollContainer={scrollElement}
|
||||||
threshold={overscanPx}
|
threshold={overscanPx}
|
||||||
placeholderClass="message-stream-placeholder"
|
placeholderClass="message-stream-placeholder"
|
||||||
virtualizationEnabled={virtualizationEnabled}
|
virtualizationEnabled={itemVirtualizationEnabled}
|
||||||
suspendMeasurements={suspendMeasurements}
|
suspendMeasurements={suspendMeasurements}
|
||||||
onHeightChange={(nextHeight, previousHeight) => {
|
onHeightChange={(nextHeight, previousHeight, meta: VirtualItemHeightChangeMeta) => {
|
||||||
const delta = nextHeight - previousHeight
|
const delta = nextHeight - previousHeight
|
||||||
|
|
||||||
// Follow mode: keep the viewport pinned to the bottom as
|
// 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
|
// while scrolling upward, compensate scrollTop so visible
|
||||||
// content stays stable.
|
// content stays stable.
|
||||||
if (delta) {
|
if (delta) {
|
||||||
|
if (meta.isStaleCacheCorrection) return
|
||||||
scheduleScrollCompensation(key(), delta)
|
scheduleScrollCompensation(key(), delta)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>{() => props.renderItem(item(), index)}</VirtualItem>
|
||||||
{() => props.renderItem(item(), index)}
|
|
||||||
</VirtualItem>
|
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Index>
|
</Index>
|
||||||
|
|||||||
@@ -167,10 +167,17 @@ interface VirtualItemProps {
|
|||||||
forceVisible?: Accessor<boolean>
|
forceVisible?: Accessor<boolean>
|
||||||
suspendMeasurements?: Accessor<boolean>
|
suspendMeasurements?: Accessor<boolean>
|
||||||
onMeasured?: () => void
|
onMeasured?: () => void
|
||||||
onHeightChange?: (nextHeight: number, previousHeight: number) => void
|
onHeightChange?: (nextHeight: number, previousHeight: number, meta: VirtualItemHeightChangeMeta) => void
|
||||||
id?: string
|
id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VirtualItemHeightChangeMeta {
|
||||||
|
source: "initial-visible-measure" | "resize"
|
||||||
|
previousCachedHeight: number | null
|
||||||
|
isStaleCacheCorrection: boolean
|
||||||
|
wasHidden: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export default function VirtualItem(props: VirtualItemProps) {
|
export default function VirtualItem(props: VirtualItemProps) {
|
||||||
const resolveContent = () => (typeof props.children === "function" ? (props.children as () => JSX.Element)() : props.children)
|
const resolveContent = () => (typeof props.children === "function" ? (props.children as () => JSX.Element)() : props.children)
|
||||||
const cachedHeight = sizeCache.get(props.cacheKey)
|
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
|
// When content first mounts, onHeightChange deltas should reflect the DOM's
|
||||||
// placeholder height (not 0), otherwise scroll compensation can overshoot.
|
// placeholder height (not 0), otherwise scroll compensation can overshoot.
|
||||||
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? fallbackPlaceholderHeight())
|
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? fallbackPlaceholderHeight())
|
||||||
const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined)
|
|
||||||
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
|
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
|
||||||
let pendingVisibility: boolean | null = null
|
let pendingVisibility: boolean | null = null
|
||||||
let visibilityFrame: number | null = null
|
let visibilityFrame: number | null = null
|
||||||
|
let awaitingVisibleMeasurement = true
|
||||||
|
let lastMeasurementWhileHidden = true
|
||||||
const flushVisibility = () => {
|
const flushVisibility = () => {
|
||||||
if (visibilityFrame !== null) {
|
if (visibilityFrame !== null) {
|
||||||
cancelAnimationFrame(visibilityFrame)
|
cancelAnimationFrame(visibilityFrame)
|
||||||
@@ -210,14 +218,14 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
}
|
}
|
||||||
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
||||||
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
||||||
|
const forceVisible = () => Boolean(props.forceVisible?.())
|
||||||
const shouldHideContent = createMemo(() => {
|
const shouldHideContent = createMemo(() => {
|
||||||
if (props.forceVisible?.()) return false
|
if (forceVisible()) return false
|
||||||
if (!virtualizationEnabled()) return false
|
if (!virtualizationEnabled()) return false
|
||||||
return !isIntersecting()
|
return !isIntersecting()
|
||||||
})
|
})
|
||||||
|
|
||||||
let wrapperRef: HTMLDivElement | undefined
|
let wrapperRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
let contentRef: HTMLDivElement | undefined
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
let resizeObserver: ResizeObserver | 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() {
|
function cleanupIntersectionObserver() {
|
||||||
if (intersectionCleanup) {
|
if (intersectionCleanup) {
|
||||||
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) {
|
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const before = measuredHeight()
|
const before = measuredHeight()
|
||||||
const normalized = nextHeight
|
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.
|
// Only keep the previous measurement when the element reports 0 height.
|
||||||
// Allow shrinkage so placeholder height matches real content height;
|
// Allow shrinkage so placeholder height matches real content height;
|
||||||
// keeping the max height can cause mount/unmount jitter near the
|
// keeping the max height can cause mount/unmount jitter near the
|
||||||
@@ -254,32 +284,38 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
hasReportedMeasurement = true
|
hasReportedMeasurement = true
|
||||||
props.onMeasured?.()
|
props.onMeasured?.()
|
||||||
}
|
}
|
||||||
setHasMeasured(true)
|
|
||||||
sizeCache.set(props.cacheKey, previous)
|
sizeCache.set(props.cacheKey, previous)
|
||||||
setMeasuredHeight(previous)
|
setMeasuredHeight(previous)
|
||||||
if (previous !== before) props.onHeightChange?.(previous, before)
|
if (previous !== before) props.onHeightChange?.(previous, before, measurementMeta)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (normalized > 0) {
|
if (normalized > 0) {
|
||||||
sizeCache.set(props.cacheKey, normalized)
|
sizeCache.set(props.cacheKey, normalized)
|
||||||
setHasMeasured(true)
|
|
||||||
if (!hasReportedMeasurement) {
|
if (!hasReportedMeasurement) {
|
||||||
hasReportedMeasurement = true
|
hasReportedMeasurement = true
|
||||||
props.onMeasured?.()
|
props.onMeasured?.()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setMeasuredHeight(normalized)
|
setMeasuredHeight(normalized)
|
||||||
if (normalized !== before) props.onHeightChange?.(normalized, before)
|
if (normalized !== before) props.onHeightChange?.(normalized, before, measurementMeta)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMeasuredHeight() {
|
function updateMeasuredHeight() {
|
||||||
if (!contentRef || measurementsSuspended()) return
|
if (!contentRef) return
|
||||||
|
if (measurementsSuspended()) return
|
||||||
// Prefer subpixel-accurate height for scroll compensation.
|
// Prefer subpixel-accurate height for scroll compensation.
|
||||||
// offsetHeight rounds to integers which can accumulate error.
|
// offsetHeight rounds to integers which can accumulate error.
|
||||||
const rect = contentRef.getBoundingClientRect()
|
const rect = contentRef.getBoundingClientRect()
|
||||||
const next = Math.max(0, Math.round(rect.height * 2) / 2)
|
const next = Math.max(0, Math.round(rect.height * 2) / 2)
|
||||||
if (next === measuredHeight()) return
|
const currentMeasured = measuredHeight()
|
||||||
persistMeasurement(next)
|
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() {
|
function setupResizeObserver() {
|
||||||
@@ -377,16 +413,17 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
cleanupResizeObserver()
|
cleanupResizeObserver()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (shouldHideContent() || measurementsSuspended()) {
|
const hidden = shouldHideContent()
|
||||||
|
if (hidden) {
|
||||||
|
awaitingVisibleMeasurement = true
|
||||||
|
lastMeasurementWhileHidden = true
|
||||||
|
}
|
||||||
|
if (hidden || measurementsSuspended()) {
|
||||||
cleanupResizeObserver()
|
cleanupResizeObserver()
|
||||||
} else if (contentRef) {
|
}
|
||||||
queueMicrotask(() => {
|
if (!hidden && !measurementsSuspended() && contentRef) {
|
||||||
updateMeasuredHeight()
|
scheduleVisibleMeasurements()
|
||||||
setupResizeObserver()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -397,10 +434,8 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
const cached = sizeCache.get(key)
|
const cached = sizeCache.get(key)
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
setMeasuredHeight(cached)
|
setMeasuredHeight(cached)
|
||||||
setHasMeasured(true)
|
|
||||||
} else {
|
} else {
|
||||||
setMeasuredHeight(fallbackPlaceholderHeight())
|
setMeasuredHeight(fallbackPlaceholderHeight())
|
||||||
setHasMeasured(false)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
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.title": "Starting instance...",
|
||||||
"folderSelection.loading.subtitle": "Hang tight while we prepare your workspace.",
|
"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.title": "Select Workspace",
|
||||||
"folderSelection.dialog.description": "Select workspace to start coding.",
|
"folderSelection.dialog.description": "Select workspace to start coding.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.loading.title": "Iniciando instancia...",
|
"folderSelection.loading.title": "Iniciando instancia...",
|
||||||
"folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.",
|
"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.title": "Seleccionar workspace",
|
||||||
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
|
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.loading.title": "Démarrage de l'instance...",
|
"folderSelection.loading.title": "Démarrage de l'instance...",
|
||||||
"folderSelection.loading.subtitle": "Patientez pendant que nous préparons votre espace de travail.",
|
"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.title": "Sélectionner l'espace de travail",
|
||||||
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
|
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.loading.title": "インスタンスを起動中...",
|
"folderSelection.loading.title": "インスタンスを起動中...",
|
||||||
"folderSelection.loading.subtitle": "ワークスペースを準備しています。しばらくお待ちください。",
|
"folderSelection.loading.subtitle": "ワークスペースを準備しています。しばらくお待ちください。",
|
||||||
|
|
||||||
|
"folderSelection.drop.title": "フォルダをドロップして開く",
|
||||||
|
"folderSelection.drop.subtitle": "ドロップしたフォルダで新しいインスタンスを開始します。",
|
||||||
|
"folderSelection.drop.invalidTitle": "ドロップした項目を開けませんでした",
|
||||||
|
"folderSelection.drop.invalidMessage": "新しいインスタンスを開始するにはフォルダをドロップしてください。",
|
||||||
|
|
||||||
"folderSelection.dialog.title": "ワークスペースを選択",
|
"folderSelection.dialog.title": "ワークスペースを選択",
|
||||||
"folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。",
|
"folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.loading.title": "Запуск экземпляра…",
|
"folderSelection.loading.title": "Запуск экземпляра…",
|
||||||
"folderSelection.loading.subtitle": "Подождите, пока мы подготовим рабочее пространство.",
|
"folderSelection.loading.subtitle": "Подождите, пока мы подготовим рабочее пространство.",
|
||||||
|
|
||||||
|
"folderSelection.drop.title": "Перетащите папку, чтобы открыть ее",
|
||||||
|
"folderSelection.drop.subtitle": "Запустите новый экземпляр в перетащенной папке.",
|
||||||
|
"folderSelection.drop.invalidTitle": "Не удалось открыть перетащенный элемент",
|
||||||
|
"folderSelection.drop.invalidMessage": "Перетащите папку, чтобы запустить новый экземпляр.",
|
||||||
|
|
||||||
"folderSelection.dialog.title": "Выберите рабочее пространство",
|
"folderSelection.dialog.title": "Выберите рабочее пространство",
|
||||||
"folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.",
|
"folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.loading.title": "正在启动实例...",
|
"folderSelection.loading.title": "正在启动实例...",
|
||||||
"folderSelection.loading.subtitle": "正在准备你的工作区,请稍候。",
|
"folderSelection.loading.subtitle": "正在准备你的工作区,请稍候。",
|
||||||
|
|
||||||
|
"folderSelection.drop.title": "拖放文件夹以打开",
|
||||||
|
"folderSelection.drop.subtitle": "在拖放的文件夹中启动一个新实例。",
|
||||||
|
"folderSelection.drop.invalidTitle": "无法打开拖放的项目",
|
||||||
|
"folderSelection.drop.invalidMessage": "请拖放一个文件夹来启动新实例。",
|
||||||
|
|
||||||
"folderSelection.dialog.title": "选择工作区",
|
"folderSelection.dialog.title": "选择工作区",
|
||||||
"folderSelection.dialog.description": "选择工作区以开始编码。",
|
"folderSelection.dialog.description": "选择工作区以开始编码。",
|
||||||
} as const
|
} 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/buttons.css";
|
||||||
@import "./components/badges.css";
|
@import "./components/badges.css";
|
||||||
|
@import "./components/folder-drop.css";
|
||||||
@import "./components/folder-loading.css";
|
@import "./components/folder-loading.css";
|
||||||
@import "./components/dropdown.css";
|
@import "./components/dropdown.css";
|
||||||
@import "./components/selector.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>
|
getCliStatus?: () => Promise<unknown>
|
||||||
restartCli?: () => Promise<unknown>
|
restartCli?: () => Promise<unknown>
|
||||||
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
||||||
|
getDirectoryPaths?: (paths: string[]) => Promise<string[]>
|
||||||
|
getPathForFile?: (file: File) => string | null
|
||||||
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
|
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
|
||||||
|
|
||||||
showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }>
|
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 {
|
interface TauriDialogModule {
|
||||||
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
|
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
|
||||||
save?: (options: Record<string, unknown>) => Promise<string | null>
|
save?: (options: Record<string, unknown>) => Promise<string | null>
|
||||||
@@ -40,6 +55,9 @@ declare global {
|
|||||||
interface TauriBridge {
|
interface TauriBridge {
|
||||||
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
||||||
dialog?: TauriDialogModule
|
dialog?: TauriDialogModule
|
||||||
|
event?: {
|
||||||
|
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|||||||
Reference in New Issue
Block a user