feat(desktop): unify folder drag-and-drop across runtimes

This commit is contained in:
Shantur Rathore
2026-03-10 22:12:23 +00:00
parent d836d2e62d
commit 429825f434
15 changed files with 538 additions and 9 deletions

View File

@@ -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) {

View File

@@ -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),
}

View File

@@ -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(())
}

View File

@@ -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

View 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,
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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 () => {}
}
}

View 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);
}

View File

@@ -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";

View File

@@ -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 {