Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions src/features/app/components/OpenAppMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,23 +121,38 @@ export function OpenAppMenu({
};
}, [openMenuOpen]);

const resolveAppName = (target: OpenTarget) =>
(target.target.appName ?? "").trim();
const resolveCommand = (target: OpenTarget) =>
(target.target.command ?? "").trim();
const canOpenTarget = (target: OpenTarget) => {
if (target.target.kind === "finder") {
return true;
}
if (target.target.kind === "command") {
return Boolean(resolveCommand(target));
}
return Boolean(resolveAppName(target));
};

const openWithTarget = async (target: OpenTarget) => {
try {
if (target.target.kind === "finder") {
await revealItemInDir(path);
return;
}
if (target.target.kind === "command") {
if (!target.target.command) {
const command = resolveCommand(target);
if (!command) {
return;
}
await openWorkspaceIn(path, {
command: target.target.command,
command,
args: target.target.args,
});
return;
}
const appName = target.target.appName || target.label;
const appName = resolveAppName(target);
if (!appName) {
return;
}
Expand All @@ -151,29 +166,40 @@ export function OpenAppMenu({
};

const handleOpen = async () => {
if (!selectedOpenTarget) {
if (!selectedOpenTarget || !canOpenTarget(selectedOpenTarget)) {
return;
}
await openWithTarget(selectedOpenTarget);
};

const handleSelectOpenTarget = async (target: OpenTarget) => {
if (!canOpenTarget(target)) {
return;
}
onSelectOpenAppId(target.id);
window.localStorage.setItem(OPEN_APP_STORAGE_KEY, target.id);
setOpenMenuOpen(false);
await openWithTarget(target);
};

const selectedCanOpen = canOpenTarget(selectedOpenTarget);
const openLabel = selectedCanOpen
? `Open in ${selectedOpenTarget.label}`
: selectedOpenTarget.target.kind === "command"
? "Set command in Settings"
: "Set app name in Settings";

return (
<div className="open-app-menu" ref={openMenuRef}>
<div className="open-app-button">
<button
type="button"
className="ghost main-header-action open-app-action"
onClick={handleOpen}
disabled={!selectedCanOpen}
data-tauri-drag-region="false"
aria-label={`Open in ${selectedOpenTarget.label}`}
title={`Open in ${selectedOpenTarget.label}`}
title={openLabel}
>
<span className="open-app-label">
<img
Expand Down Expand Up @@ -201,13 +227,15 @@ export function OpenAppMenu({
{openMenuOpen && (
<div className="open-app-dropdown" role="menu">
{resolvedOpenTargets.map((target) => (
// Keep entries visible but disable ones missing required config.
<button
key={target.id}
type="button"
className={`open-app-option${
target.id === resolvedOpenAppId ? " is-active" : ""
}`}
onClick={() => handleSelectOpenTarget(target)}
disabled={!canOpenTarget(target)}
role="menuitem"
data-tauri-drag-region="false"
>
Expand Down
33 changes: 27 additions & 6 deletions src/features/messages/hooks/useFileLinkOpener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ const DEFAULT_OPEN_TARGET: OpenTarget = {
args: [],
};

const resolveAppName = (target: OpenTarget) => (target.appName ?? "").trim();
const resolveCommand = (target: OpenTarget) => (target.command ?? "").trim();
const canOpenTarget = (target: OpenTarget) => {
if (target.kind === "finder") {
return true;
}
if (target.kind === "command") {
return Boolean(resolveCommand(target));
}
return Boolean(resolveAppName(target));
};

function resolveFilePath(path: string, workspacePath?: string | null) {
const trimmed = path.trim();
if (!workspacePath) {
Expand Down Expand Up @@ -94,23 +106,27 @@ export function useFileLinkOpener(
const resolvedPath = resolveFilePath(stripLineSuffix(rawPath), workspacePath);

try {
if (!canOpenTarget(target)) {
return;
}
if (target.kind === "finder") {
await revealItemInDir(resolvedPath);
return;
}

if (target.kind === "command") {
if (!target.command) {
const command = resolveCommand(target);
if (!command) {
return;
}
await openWorkspaceIn(resolvedPath, {
command: target.command,
command,
args: target.args,
});
return;
}

const appName = (target.appName || target.label || "").trim();
const appName = resolveAppName(target);
if (!appName) {
return;
}
Expand Down Expand Up @@ -143,18 +159,23 @@ export function useFileLinkOpener(
openTargets[0]),
};
const resolvedPath = resolveFilePath(stripLineSuffix(rawPath), workspacePath);
const appName = (target.appName || target.label || "").trim();
const appName = resolveAppName(target);
const command = resolveCommand(target);
const canOpen = canOpenTarget(target);
const openLabel =
target.kind === "finder"
? revealLabel()
: target.kind === "command"
? `Open in ${target.label}`
? command
? `Open in ${target.label}`
: "Set command in Settings"
: appName
? `Open in ${appName}`
: "Open Link";
: "Set app name in Settings";
const items = [
await MenuItem.new({
text: openLabel,
enabled: canOpen,
action: async () => {
await openFileLink(rawPath);
},
Expand Down
72 changes: 70 additions & 2 deletions src/features/settings/components/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,34 @@ const buildOpenAppDrafts = (targets: OpenAppTarget[]): OpenAppDraft[] =>
argsText: target.args.join(" "),
}));

const isOpenAppLabelValid = (label: string) => label.trim().length > 0;

const isOpenAppDraftComplete = (draft: OpenAppDraft) => {
if (!isOpenAppLabelValid(draft.label)) {
return false;
}
if (draft.kind === "app") {
return Boolean(draft.appName?.trim());
}
if (draft.kind === "command") {
return Boolean(draft.command?.trim());
}
return true;
};

const isOpenAppTargetComplete = (target: OpenAppTarget) => {
if (!isOpenAppLabelValid(target.label)) {
return false;
}
if (target.kind === "app") {
return Boolean(target.appName?.trim());
}
if (target.kind === "command") {
return Boolean(target.command?.trim());
}
return true;
};

const createOpenAppId = () => {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
Expand Down Expand Up @@ -702,8 +730,13 @@ export function SettingsView({
const handleCommitOpenApps = useCallback(
async (drafts: OpenAppDraft[], selectedId = openAppSelectedId) => {
const nextTargets = normalizeOpenAppTargets(drafts);
const resolvedSelectedId = nextTargets.find(
(target) => target.id === selectedId && isOpenAppTargetComplete(target),
)?.id;
const firstCompleteId = nextTargets.find(isOpenAppTargetComplete)?.id;
const nextSelectedId =
nextTargets.find((target) => target.id === selectedId)?.id ??
resolvedSelectedId ??
firstCompleteId ??
nextTargets[0]?.id ??
DEFAULT_OPEN_APP_ID;
setOpenAppDrafts(buildOpenAppDrafts(nextTargets));
Expand Down Expand Up @@ -796,6 +829,10 @@ export function SettingsView({
};

const handleSelectOpenAppDefault = (id: string) => {
const selectedTarget = openAppDrafts.find((target) => target.id === id);
if (selectedTarget && !isOpenAppDraftComplete(selectedTarget)) {
return;
}
setOpenAppSelectedId(id);
if (typeof window !== "undefined") {
window.localStorage.setItem(OPEN_APP_STORAGE_KEY, id);
Expand Down Expand Up @@ -2401,8 +2438,26 @@ export function SettingsView({
getKnownOpenAppIcon(target.id) ??
openAppIconById[target.id] ??
GENERIC_APP_ICON;
const labelValid = isOpenAppLabelValid(target.label);
const appNameValid =
target.kind !== "app" || Boolean(target.appName?.trim());
const commandValid =
target.kind !== "command" || Boolean(target.command?.trim());
const isComplete = labelValid && appNameValid && commandValid;
const incompleteHint = !labelValid
? "Label required"
: target.kind === "app"
? "App name required"
: target.kind === "command"
? "Command required"
: "Complete required fields";
return (
<div key={target.id} className="settings-open-app-row">
<div
key={target.id}
className={`settings-open-app-row${
isComplete ? "" : " is-incomplete"
}`}
>
<div className="settings-open-app-icon-wrap" aria-hidden>
<img
className="settings-open-app-icon"
Expand All @@ -2428,6 +2483,7 @@ export function SettingsView({
void handleCommitOpenApps(openAppDrafts);
}}
aria-label={`Open app label ${index + 1}`}
data-invalid={!labelValid || undefined}
/>
</label>
<label className="settings-open-app-field settings-open-app-field--type">
Expand Down Expand Up @@ -2464,6 +2520,7 @@ export function SettingsView({
void handleCommitOpenApps(openAppDrafts);
}}
aria-label={`Open app name ${index + 1}`}
data-invalid={!appNameValid || undefined}
/>
</label>
)}
Expand All @@ -2483,6 +2540,7 @@ export function SettingsView({
void handleCommitOpenApps(openAppDrafts);
}}
aria-label={`Open app command ${index + 1}`}
data-invalid={!commandValid || undefined}
/>
</label>
)}
Expand All @@ -2507,12 +2565,22 @@ export function SettingsView({
)}
</div>
<div className="settings-open-app-actions">
{!isComplete && (
<span
className="settings-open-app-status"
title={incompleteHint}
aria-label={incompleteHint}
>
Incomplete
</span>
)}
<label className="settings-open-app-default">
<input
type="radio"
name="open-app-default"
checked={target.id === openAppSelectedId}
onChange={() => handleSelectOpenAppDefault(target.id)}
disabled={!isComplete}
/>
Default
</label>
Expand Down
18 changes: 18 additions & 0 deletions src/styles/settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,10 @@
flex-wrap: wrap;
}

.settings-open-app-row.is-incomplete {
border-color: color-mix(in srgb, var(--status-error) 45%, var(--border-muted));
}

.settings-open-app-icon-wrap {
flex: 0 0 auto;
width: 24px;
Expand Down Expand Up @@ -535,6 +539,20 @@
flex: 0 0 auto;
}

.settings-open-app-status {
font-size: 11px;
color: var(--status-error);
padding: 2px 6px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--status-error) 40%, transparent);
background: color-mix(in srgb, var(--status-error) 12%, transparent);
}

.settings-open-app-input[data-invalid] {
border-color: color-mix(in srgb, var(--status-error) 60%, var(--border-muted));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--status-error) 25%, transparent);
}

.settings-open-app-default {
display: inline-flex;
align-items: center;
Expand Down