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

This commit is contained in:
Filip
2026-02-11 11:40:52 +01:00
committed by GitHub
parent 5ba4c0e024
commit cf7a1b8d80
2 changed files with 351 additions and 88 deletions

View File

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

View File

@@ -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;
}
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 paths = String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.collect::<Vec<_>>();
let bat = path.with_extension("bat");
if bat.exists() {
if let Some(resolved) = resolve_cmd(&bat) {
return Some(resolved);
}
}
if paths.is_empty() {
return None;
}
}
let key = app_name
.chars()
.filter(|v| v.is_ascii_alphanumeric())
.flat_map(|v| v.to_lowercase())
.collect::<String>();
if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) {
return Some(path.to_string_lossy().to_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()),
];
if has_ext(path, "cmd") || has_ext(path, "bat") {
if let Some(resolved) = resolve_cmd(path) {
return Some(resolved);
}
}
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 path.extension().is_none() {
let cmd = path.with_extension("cmd");
if cmd.exists() {
if let Some(resolved) = resolve_cmd(&cmd) {
return Some(resolved);
}
}
let Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else {
continue;
};
let bat = path.with_extension("bat");
if bat.exists() {
if let Some(resolved) = resolve_cmd(&bat) {
return Some(resolved);
}
}
}
}
let name = stem
.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()),
];
if name.contains(&key) || key.contains(&name) {
return Some(candidate.to_string_lossy().to_string());
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 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());
}
}
}
}
}
}
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]