Browse Source

feat(desktop): enhance Windows app resolution and UI loading states (#13084)

Filip 2 weeks ago
parent
commit
cf7a1b8d80

+ 42 - 17
packages/app/src/components/session/session-header.tsx

@@ -1,4 +1,4 @@
-import { createEffect, createMemo, onCleanup, Show } from "solid-js"
+import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Portal } from "solid-js/web"
 import { useParams } from "@solidjs/router"
@@ -18,6 +18,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Button } from "@opencode-ai/ui/button"
 import { AppIcon } from "@opencode-ai/ui/app-icon"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { Spinner } from "@opencode-ai/ui/spinner"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { Popover } from "@opencode-ai/ui/popover"
 import { TextField } from "@opencode-ai/ui/text-field"
@@ -167,6 +168,7 @@ export function SessionHeader() {
 
   const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
   const [menu, setMenu] = createStore({ open: false })
+  const [openRequest, setOpenRequest] = createStore({ app: undefined as OpenApp | undefined, version: 0 })
 
   const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
   const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
@@ -179,20 +181,32 @@ export function SessionHeader() {
     setPrefs("app", options()[0]?.id ?? "finder")
   })
 
-  const openDir = (app: OpenApp) => {
-    const directory = projectDirectory()
-    if (!directory) return
-    if (!canOpen()) return
-
-    const item = options().find((o) => o.id === app)
-    const openWith = item && "openWith" in item ? item.openWith : undefined
-    Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => {
-      showToast({
-        variant: "error",
-        title: language.t("common.requestFailed"),
-        description: err instanceof Error ? err.message : String(err),
-      })
+  const [openTask] = createResource(
+    () => openRequest.app && openRequest.version,
+    async () => {
+      const app = openRequest.app
+      const directory = projectDirectory()
+      if (!app || !directory || !canOpen()) return
+
+      const item = options().find((o) => o.id === app)
+      const openWith = item && "openWith" in item ? item.openWith : undefined
+      await platform.openPath?.(directory, openWith)
+    },
+  )
+
+  createEffect(() => {
+    const err = openTask.error
+    if (!err) return
+    showToast({
+      variant: "error",
+      title: language.t("common.requestFailed"),
+      description: err instanceof Error ? err.message : String(err),
     })
+  })
+
+  const openDir = (app: OpenApp) => {
+    if (openTask.loading) return
+    setOpenRequest({ app, version: openRequest.version + 1 })
   }
 
   const copyPath = () => {
@@ -346,12 +360,18 @@ export function SessionHeader() {
                       <div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden">
                         <Button
                           variant="ghost"
-                          class="rounded-none h-full py-0 pr-3 pl-2 gap-1.5 border-none shadow-none"
+                          class="rounded-none h-full py-0 pr-3 pl-2 gap-1.5 border-none shadow-none disabled:!cursor-default"
+                          classList={{
+                            "bg-surface-raised-base-active": openTask.loading,
+                          }}
                           onClick={() => openDir(current().id)}
+                          disabled={openTask.loading}
                           aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
                         >
                           <div class="flex size-5 shrink-0 items-center justify-center">
-                            <AppIcon id={current().icon} class="size-4" />
+                            <Show when={openTask.loading} fallback={<AppIcon id={current().icon} class="size-4" />}>
+                              <Spinner class="size-3.5 text-icon-base" />
+                            </Show>
                           </div>
                           <span class="text-12-regular text-text-strong">Open</span>
                         </Button>
@@ -366,7 +386,11 @@ export function SessionHeader() {
                             as={IconButton}
                             icon="chevron-down"
                             variant="ghost"
-                            class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active"
+                            disabled={openTask.loading}
+                            class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
+                            classList={{
+                              "bg-surface-raised-base-active": openTask.loading,
+                            }}
                             aria-label={language.t("session.header.open.menu")}
                           />
                           <DropdownMenu.Portal>
@@ -383,6 +407,7 @@ export function SessionHeader() {
                                   {options().map((o) => (
                                     <DropdownMenu.RadioItem
                                       value={o.id}
+                                      disabled={openTask.loading}
                                       onSelect={() => {
                                         setMenu("open", false)
                                         openDir(o.id)

+ 308 - 70
packages/desktop/src-tauri/src/lib.rs

@@ -172,28 +172,211 @@ fn check_app_exists(app_name: &str) -> bool {
 }
 
 #[cfg(target_os = "windows")]
-fn check_windows_app(_app_name: &str) -> bool {
-    // Check if command exists in PATH, including .exe
-    return true;
+fn check_windows_app(app_name: &str) -> bool {
+    resolve_windows_app_path(app_name).is_some()
 }
 
 #[cfg(target_os = "windows")]
 fn resolve_windows_app_path(app_name: &str) -> Option<String> {
     use std::path::{Path, PathBuf};
 
-    // Try to find the command using 'where'
-    let output = Command::new("where").arg(app_name).output().ok()?;
+    fn expand_env(value: &str) -> String {
+        let mut out = String::with_capacity(value.len());
+        let mut index = 0;
 
-    if !output.status.success() {
+        while let Some(start) = value[index..].find('%') {
+            let start = index + start;
+            out.push_str(&value[index..start]);
+
+            let Some(end_rel) = value[start + 1..].find('%') else {
+                out.push_str(&value[start..]);
+                return out;
+            };
+
+            let end = start + 1 + end_rel;
+            let key = &value[start + 1..end];
+            if key.is_empty() {
+                out.push('%');
+                index = end + 1;
+                continue;
+            }
+
+            if let Ok(v) = std::env::var(key) {
+                out.push_str(&v);
+                index = end + 1;
+                continue;
+            }
+
+            out.push_str(&value[start..=end]);
+            index = end + 1;
+        }
+
+        out.push_str(&value[index..]);
+        out
+    }
+
+    fn extract_exe(value: &str) -> Option<String> {
+        let value = value.trim();
+        if value.is_empty() {
+            return None;
+        }
+
+        if let Some(rest) = value.strip_prefix('"') {
+            if let Some(end) = rest.find('"') {
+                let inner = rest[..end].trim();
+                if inner.to_ascii_lowercase().contains(".exe") {
+                    return Some(inner.to_string());
+                }
+            }
+        }
+
+        let lower = value.to_ascii_lowercase();
+        let end = lower.find(".exe")?;
+        Some(value[..end + 4].trim().trim_matches('"').to_string())
+    }
+
+    fn candidates(app_name: &str) -> Vec<String> {
+        let app_name = app_name.trim().trim_matches('"');
+        if app_name.is_empty() {
+            return vec![];
+        }
+
+        let mut out = Vec::<String>::new();
+        let mut push = |value: String| {
+            let value = value.trim().trim_matches('"').to_string();
+            if value.is_empty() {
+                return;
+            }
+            if out.iter().any(|v| v.eq_ignore_ascii_case(&value)) {
+                return;
+            }
+            out.push(value);
+        };
+
+        push(app_name.to_string());
+
+        let lower = app_name.to_ascii_lowercase();
+        if !lower.ends_with(".exe") {
+            push(format!("{app_name}.exe"));
+        }
+
+        let snake = {
+            let mut s = String::new();
+            let mut underscore = false;
+            for c in lower.chars() {
+                if c.is_ascii_alphanumeric() {
+                    s.push(c);
+                    underscore = false;
+                    continue;
+                }
+                if underscore {
+                    continue;
+                }
+                s.push('_');
+                underscore = true;
+            }
+            s.trim_matches('_').to_string()
+        };
+
+        if !snake.is_empty() {
+            push(snake.clone());
+            if !snake.ends_with(".exe") {
+                push(format!("{snake}.exe"));
+            }
+        }
+
+        let alnum = lower
+            .chars()
+            .filter(|c| c.is_ascii_alphanumeric())
+            .collect::<String>();
+
+        if !alnum.is_empty() {
+            push(alnum.clone());
+            push(format!("{alnum}.exe"));
+        }
+
+        match lower.as_str() {
+            "sublime text" | "sublime-text" | "sublime_text" | "sublime text.exe" => {
+                push("subl".to_string());
+                push("subl.exe".to_string());
+                push("sublime_text".to_string());
+                push("sublime_text.exe".to_string());
+            }
+            _ => {}
+        }
+
+        out
+    }
+
+    fn reg_app_path(exe: &str) -> Option<String> {
+        let exe = exe.trim().trim_matches('"');
+        if exe.is_empty() {
+            return None;
+        }
+
+        let keys = [
+            format!(
+                r"HKCU\Software\Microsoft\Windows\CurrentVersion\App Paths\{exe}"
+            ),
+            format!(
+                r"HKLM\Software\Microsoft\Windows\CurrentVersion\App Paths\{exe}"
+            ),
+            format!(
+                r"HKLM\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\{exe}"
+            ),
+        ];
+
+        for key in keys {
+            let Some(output) = Command::new("reg")
+                .args(["query", &key, "/ve"])
+                .output()
+                .ok()
+            else {
+                continue;
+            };
+
+            if !output.status.success() {
+                continue;
+            }
+
+            let stdout = String::from_utf8_lossy(&output.stdout);
+            for line in stdout.lines() {
+                let tokens = line.split_whitespace().collect::<Vec<_>>();
+                let Some(index) = tokens.iter().position(|v| v.starts_with("REG_")) else {
+                    continue;
+                };
+
+                let value = tokens[index + 1..].join(" ");
+                let Some(exe) = extract_exe(&value) else {
+                    continue;
+                };
+
+                let exe = expand_env(&exe);
+                let path = Path::new(exe.trim().trim_matches('"'));
+                if path.exists() {
+                    return Some(path.to_string_lossy().to_string());
+                }
+            }
+        }
+
+        None
+    }
+
+    let app_name = app_name.trim().trim_matches('"');
+    if app_name.is_empty() {
         return None;
     }
 
-    let paths = String::from_utf8_lossy(&output.stdout)
-        .lines()
-        .map(str::trim)
-        .filter(|line| !line.is_empty())
-        .map(PathBuf::from)
-        .collect::<Vec<_>>();
+    let direct = Path::new(app_name);
+    if direct.is_absolute() && direct.exists() {
+        return Some(direct.to_string_lossy().to_string());
+    }
+
+    let key = app_name
+        .chars()
+        .filter(|v| v.is_ascii_alphanumeric())
+        .flat_map(|v| v.to_lowercase())
+        .collect::<String>();
 
     let has_ext = |path: &Path, ext: &str| {
         path.extension()
@@ -202,22 +385,19 @@ fn resolve_windows_app_path(app_name: &str) -> Option<String> {
             .unwrap_or(false)
     };
 
-    if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) {
-        return Some(path.to_string_lossy().to_string());
-    }
-
     let resolve_cmd = |path: &Path| -> Option<String> {
-        let content = std::fs::read_to_string(path).ok()?;
+        let bytes = std::fs::read(path).ok()?;
+        let content = String::from_utf8_lossy(&bytes);
 
         for token in content.split('"') {
-            let lower = token.to_ascii_lowercase();
-            if !lower.contains(".exe") {
+            let Some(exe) = extract_exe(token) else {
                 continue;
-            }
+            };
 
+            let lower = exe.to_ascii_lowercase();
             if let Some(index) = lower.find("%~dp0") {
                 let base = path.parent()?;
-                let suffix = &token[index + 5..];
+                let suffix = &exe[index + 5..];
                 let mut resolved = PathBuf::from(base);
 
                 for part in suffix.replace('/', "\\").split('\\') {
@@ -234,9 +414,11 @@ fn resolve_windows_app_path(app_name: &str) -> Option<String> {
                 if resolved.exists() {
                     return Some(resolved.to_string_lossy().to_string());
                 }
+
+                continue;
             }
 
-            let resolved = PathBuf::from(token);
+            let resolved = PathBuf::from(expand_env(&exe));
             if resolved.exists() {
                 return Some(resolved.to_string_lossy().to_string());
             }
@@ -245,74 +427,130 @@ fn resolve_windows_app_path(app_name: &str) -> Option<String> {
         None
     };
 
-    for path in &paths {
-        if has_ext(path, "cmd") || has_ext(path, "bat") {
-            if let Some(resolved) = resolve_cmd(path) {
-                return Some(resolved);
-            }
+    let resolve_where = |query: &str| -> Option<String> {
+        let output = Command::new("where").arg(query).output().ok()?;
+        if !output.status.success() {
+            return None;
+        }
+
+        let paths = String::from_utf8_lossy(&output.stdout)
+            .lines()
+            .map(str::trim)
+            .filter(|line| !line.is_empty())
+            .map(PathBuf::from)
+            .collect::<Vec<_>>();
+
+        if paths.is_empty() {
+            return None;
+        }
+
+        if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) {
+            return Some(path.to_string_lossy().to_string());
         }
 
-        if path.extension().is_none() {
-            let cmd = path.with_extension("cmd");
-            if cmd.exists() {
-                if let Some(resolved) = resolve_cmd(&cmd) {
+        for path in &paths {
+            if has_ext(path, "cmd") || has_ext(path, "bat") {
+                if let Some(resolved) = resolve_cmd(path) {
                     return Some(resolved);
                 }
             }
 
-            let bat = path.with_extension("bat");
-            if bat.exists() {
-                if let Some(resolved) = resolve_cmd(&bat) {
-                    return Some(resolved);
+            if path.extension().is_none() {
+                let cmd = path.with_extension("cmd");
+                if cmd.exists() {
+                    if let Some(resolved) = resolve_cmd(&cmd) {
+                        return Some(resolved);
+                    }
+                }
+
+                let bat = path.with_extension("bat");
+                if bat.exists() {
+                    if let Some(resolved) = resolve_cmd(&bat) {
+                        return Some(resolved);
+                    }
                 }
             }
         }
-    }
 
-    let key = app_name
-        .chars()
-        .filter(|v| v.is_ascii_alphanumeric())
-        .flat_map(|v| v.to_lowercase())
-        .collect::<String>();
-
-    if !key.is_empty() {
-        for path in &paths {
-            let dirs = [
-                path.parent(),
-                path.parent().and_then(|dir| dir.parent()),
-                path.parent()
-                    .and_then(|dir| dir.parent())
-                    .and_then(|dir| dir.parent()),
-            ];
-
-            for dir in dirs.into_iter().flatten() {
-                if let Ok(entries) = std::fs::read_dir(dir) {
-                    for entry in entries.flatten() {
-                        let candidate = entry.path();
-                        if !has_ext(&candidate, "exe") {
-                            continue;
-                        }
+        if !key.is_empty() {
+            for path in &paths {
+                let dirs = [
+                    path.parent(),
+                    path.parent().and_then(|dir| dir.parent()),
+                    path.parent()
+                        .and_then(|dir| dir.parent())
+                        .and_then(|dir| dir.parent()),
+                ];
+
+                for dir in dirs.into_iter().flatten() {
+                    if let Ok(entries) = std::fs::read_dir(dir) {
+                        for entry in entries.flatten() {
+                            let candidate = entry.path();
+                            if !has_ext(&candidate, "exe") {
+                                continue;
+                            }
 
-                        let Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else {
-                            continue;
-                        };
+                            let Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else {
+                                continue;
+                            };
 
-                        let name = stem
-                            .chars()
-                            .filter(|v| v.is_ascii_alphanumeric())
-                            .flat_map(|v| v.to_lowercase())
-                            .collect::<String>();
+                            let name = stem
+                                .chars()
+                                .filter(|v| v.is_ascii_alphanumeric())
+                                .flat_map(|v| v.to_lowercase())
+                                .collect::<String>();
 
-                        if name.contains(&key) || key.contains(&name) {
-                            return Some(candidate.to_string_lossy().to_string());
+                            if name.contains(&key) || key.contains(&name) {
+                                return Some(candidate.to_string_lossy().to_string());
+                            }
                         }
                     }
                 }
             }
         }
+
+        paths.first().map(|path| path.to_string_lossy().to_string())
+    };
+
+    let list = candidates(app_name);
+    for query in &list {
+        if let Some(path) = resolve_where(query) {
+            return Some(path);
+        }
     }
 
-    paths.first().map(|path| path.to_string_lossy().to_string())
+    let mut exes = Vec::<String>::new();
+    for query in &list {
+        let query = query.trim().trim_matches('"');
+        if query.is_empty() {
+            continue;
+        }
+
+        let name = Path::new(query)
+            .file_name()
+            .and_then(|v| v.to_str())
+            .unwrap_or(query);
+
+        let exe = if name.to_ascii_lowercase().ends_with(".exe") {
+            name.to_string()
+        } else {
+            format!("{name}.exe")
+        };
+
+        if exes.iter().any(|v| v.eq_ignore_ascii_case(&exe)) {
+            continue;
+        }
+
+        exes.push(exe);
+    }
+
+    for exe in exes {
+        if let Some(path) = reg_app_path(&exe) {
+            return Some(path);
+        }
+    }
+
+    None
 }
 
 #[tauri::command]