Add OpenIn dropdown behavior to files editor OpenIn button (#581)
* style: use outlined Open In file transfer icon * feat: add Open In dropdown to files editor toolbar
This commit is contained in:
@@ -7,46 +7,28 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isTauriShell, openDesktopPath, openDesktopProjectInApp, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop';
|
import { isDesktopLocalOriginActive, isTauriShell, openDesktopPath, openDesktopProjectInApp } from '@/lib/desktop';
|
||||||
import { DEFAULT_OPEN_IN_APP_ID, OPEN_IN_APPS, getOpenInAppById, type OpenInApp } from '@/lib/openInApps';
|
import { DEFAULT_OPEN_IN_APP_ID, OPEN_IN_APPS } from '@/lib/openInApps';
|
||||||
|
import { useOpenInAppsStore, type OpenInAppOption } from '@/stores/useOpenInAppsStore';
|
||||||
import { RiArrowDownSLine, RiCheckLine, RiFileCopyLine, RiRefreshLine } from '@remixicon/react';
|
import { RiArrowDownSLine, RiCheckLine, RiFileCopyLine, RiRefreshLine } from '@remixicon/react';
|
||||||
|
|
||||||
const FINDER_DEFAULT_ICON_DATA_URL = 'data:image/png;base64,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';
|
const FINDER_DEFAULT_ICON_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAeGVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAIKADAAQAAAABAAAAIAAAAAB+C9pSAAAACXBIWXMAABYlAAAWJQFJUiTwAAABnWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogICA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogICAgICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgICAgICAgICB4bWxuczpleGlmPSJodHRwOi8vbnMuYWRvYmUuY29tL2V4aWYvMS4wLyI+CiAgICAgICAgIDxleGlmOlBpeGVsWERpbWVuc2lvbj4yNTY8L2V4aWY6UGl4ZWxYRGltZW5zaW9uPgogICAgICAgICA8ZXhpZjpQaXhlbFlEaW1lbnNpb24+MjU2PC9leGlmOlBpeGVsWURpbWVuc2lvbj4KICAgICAgPC9yZGY6RGVzY3JpcHRpb24+CiAgIDwvcmRmOlJERj4KPC94OnhtcG1ldGE+Cl6wHhsAAAXaSURBVFgJ7VddbBRVFP5mdme6dOnu2tZawOAPjfxU+bGQYCRAsvw8qNGEQPTRJ0I0amL0wfjgA/HBR8KLD8YgDxJEUkVFxSYYTSRii6DQQCMGIQqlW7p0u+zMzo/fuTuzO9Nt0Td94CRn7pl7z5zznZ977y5wh/7jDGiz+T979qD5Ujbfd90xlll+stOF1uI40B1+4HhkjnZk9CgLQ9iXp2/BdcbgVc/h0sAgduywudJEMwLY9Of4ugtW5p3CpL7W1jTN88VmjdQYvnDKF1mczkYuNZLeCVg3X8fa9u+nqzUB2HRpdN2pSseRQknPoUL1Jo2ICTrPGcCzdwPdHENcAnicKRqcAk7cpL5J1r0JlAtPYV1XDETM/FtH3m19r+f5by+XjNX/xnmCX3/cCzydi4CKiC7lw+PArhGgoPPFq/6E0+9vwM6d5VBNpuv03cLNfeNTRh9KnJIiV2/PvSngycC5RD+dE5zb3g7s6QESzAZc2l6wuY9SnWIAxv10r81uU85Vt1FvtpEtlc/SMFUkUofeZ2IBta0DWDmXgkfbyTRz1qAYAMczOz3p1elOxYPyEllj421hdELViPO6Kudk3ia3UGe5ABDbvtnJZ52SdYmCZ3stdeexBabFdeAbYopEowtagVUZqFapBrtAGqpiVaFrGgyjZlrmTD5yEqoEJj4iFMuA62i6L3WPZkAiuHgarZ/vbWSBkTzO2rfTR4XOJVJhjfX44MBn+OTocVWbcF5MalxXPeVL6zYonoGo44YOtDI7qHC1lkL5nHnOc+tJRi3K6iygLNGMjt1A1XVV6iUzOvVtAvMlS2I/yBYlRf8MgA6szmXQ1jDfKhSgjft6DRtrkgarAiAw5nI9v2WDSn+Zxfd9DawGxIlPPQUg0A2HGABfEIYlCDU4+q0d8O+jRzHCCFYy+nu4BaeYAoksBCDrPYsXQQ6iitgiSQaS1FHHtMzFil4DpxTl4UhORSn4WOaaiGsbu4iFRkMnYQlEV0oSJQGQ4FyYgSRDjpqPZcCR6EOOWonIEsBqArAIQOMLzw0VXRRERF2VoA6Atk1+MzsASekMJYgaFEeHR4Cr85lNGntYzgKCYd/NSNIDCXr0ZJ2jwTsjSvEMzFQCCVmKHBRahn2DNb4rDRx8pnbXOOIg0JELLMHOF1AUkaRj1V8c2TookkMS83WK9QCVpRwtf5wCykQWRKDyJ44Ytc452QUV6inmN9IDIv/6y2+YLDuqTywBEHxv8rsoxQC4Fpf4cZ2pbJ4/huxXr0EvFmoRCrAIVymLQ3Eid0GJYPsPfISBLwdwi79YQnCqBNS7LQDP5qYSAKEDypOrX4WVWYLsFy+i9cwh6CUmUKIJI2Gq5cSbnLLw849D2Ld3L4olC1u3P0c1ow5Ozgixa3puWChONG1D3eLZUQOglvng+Vp5dBfseesx5/yHyI4cBTL3wsssRGs2g6/ppHijiMLoNSSMNHofy6Nn6SPsAR02nUoTtrDTSrdoi8CTni55rlOsCf1ypaDxlFMNU1epCV5XL6Y6dmOq+BeS48NIlq7Anpjg5dOFbPdDWLQyj/aubnUKSkMKi3NhkUd4kieYtbRbYS0bFAOQKI8NO363z1RJHmamtnlwhGksxV2w/gl29WRtm8kWtWUnRShLnQvXgDOXmLg2HzlvbDiyHD8Y517YP2i4FtueFPbB9FFqKcyobk4A5y7zquUFa7IXojyHoeXmAFcY755vaI6A56Xsofm/7+cmblBTpOldQ5vs3PJDVS+RVSAaus2SpJTO80t4NTNSOQfCDrtFkBevA0ME6HGvPdDpFlekzm7rf3nFQNRQEwBZTL9warObWfx21Uv1+fx1ERqVNampGoOHpF1tsdp07RnoGMxK1vT97rbK4IP6+Tc+fWXVsahaYGL6VO09d//GXHXr7jVeqmuppqU6ff4x0RO6lqRxgxHJpWKSlcw5eWfjq5rq/CdhaL5l6JWxjDc6bP7w5sn+/uMs2B36H2bgb6v9raK0+o9IAAAAAElFTkSuQmCC';
|
||||||
const TERMINAL_DEFAULT_ICON_DATA_URL = 'data:image/png;base64,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';
|
const TERMINAL_DEFAULT_ICON_DATA_URL = 'data:image/png;base64,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';
|
||||||
|
|
||||||
type OpenInAppOption = {
|
type OpenInAppOptionWithFallback = OpenInAppOption & {
|
||||||
id: OpenInApp['id'];
|
|
||||||
label: OpenInApp['label'];
|
|
||||||
appName: OpenInApp['appName'];
|
|
||||||
fallbackIconDataUrl?: string;
|
fallbackIconDataUrl?: string;
|
||||||
iconDataUrl?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const OPEN_IN_APP_OPTIONS: OpenInAppOption[] = OPEN_IN_APPS.map((app) => ({
|
const withFallbackIcon = (app: OpenInAppOption): OpenInAppOptionWithFallback => ({
|
||||||
...app,
|
...app,
|
||||||
fallbackIconDataUrl: app.id === 'finder'
|
fallbackIconDataUrl: app.id === 'finder'
|
||||||
? FINDER_DEFAULT_ICON_DATA_URL
|
? FINDER_DEFAULT_ICON_DATA_URL
|
||||||
: app.id === 'terminal'
|
: app.id === 'terminal'
|
||||||
? TERMINAL_DEFAULT_ICON_DATA_URL
|
? TERMINAL_DEFAULT_ICON_DATA_URL
|
||||||
: undefined,
|
: undefined,
|
||||||
}));
|
});
|
||||||
|
|
||||||
const ALWAYS_AVAILABLE_APP_IDS = new Set(['finder', 'terminal']);
|
|
||||||
const getAlwaysAvailableApps = () => OPEN_IN_APP_OPTIONS.filter((app) => ALWAYS_AVAILABLE_APP_IDS.has(app.id));
|
|
||||||
|
|
||||||
const getStoredAppId = (): string => {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return DEFAULT_OPEN_IN_APP_ID;
|
|
||||||
}
|
|
||||||
const stored = window.localStorage.getItem('openInAppId');
|
|
||||||
if (stored && getOpenInAppById(stored)) {
|
|
||||||
return stored;
|
|
||||||
}
|
|
||||||
return DEFAULT_OPEN_IN_APP_ID;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AppIcon = ({
|
const AppIcon = ({
|
||||||
label,
|
label,
|
||||||
@@ -92,179 +74,29 @@ type OpenInAppButtonProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const OpenInAppButton = ({ directory, activeFilePath, className }: OpenInAppButtonProps) => {
|
export const OpenInAppButton = ({ directory, activeFilePath, className }: OpenInAppButtonProps) => {
|
||||||
const [selectedAppId, setSelectedAppId] = React.useState(getStoredAppId);
|
const selectedAppId = useOpenInAppsStore((state) => state.selectedAppId);
|
||||||
const [availableApps, setAvailableApps] = React.useState<OpenInAppOption[]>(getAlwaysAvailableApps);
|
const availableApps = useOpenInAppsStore((state) => state.availableApps);
|
||||||
const [hasLoadedApps, setHasLoadedApps] = React.useState(false);
|
const isCacheStale = useOpenInAppsStore((state) => state.isCacheStale);
|
||||||
const [isCacheStale, setIsCacheStale] = React.useState(false);
|
const isScanning = useOpenInAppsStore((state) => state.isScanning);
|
||||||
const [isScanning, setIsScanning] = React.useState(false);
|
const initialize = useOpenInAppsStore((state) => state.initialize);
|
||||||
const isMountedRef = React.useRef(true);
|
const loadInstalledApps = useOpenInAppsStore((state) => state.loadInstalledApps);
|
||||||
const isLoadingRef = React.useRef(false);
|
const selectApp = useOpenInAppsStore((state) => state.selectApp);
|
||||||
const keepScanningRef = React.useRef(false);
|
|
||||||
const hasLoadedAppsRef = React.useRef(false);
|
|
||||||
const retryTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const retryAttemptRef = React.useRef(0);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
initialize();
|
||||||
const handler = (event: Event) => {
|
}, [initialize]);
|
||||||
const detail = (event as CustomEvent<DesktopSettings>).detail;
|
|
||||||
const nextId = detail
|
|
||||||
&& typeof detail.openInAppId === 'string'
|
|
||||||
&& detail.openInAppId.length > 0
|
|
||||||
&& getOpenInAppById(detail.openInAppId)
|
|
||||||
? detail.openInAppId
|
|
||||||
: null;
|
|
||||||
if (!nextId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.localStorage.setItem('openInAppId', nextId);
|
|
||||||
setSelectedAppId(nextId);
|
|
||||||
};
|
|
||||||
window.addEventListener('openchamber:settings-synced', handler);
|
|
||||||
return () => window.removeEventListener('openchamber:settings-synced', handler);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
isMountedRef.current = false;
|
|
||||||
if (retryTimeoutRef.current) {
|
|
||||||
clearTimeout(retryTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const setLoadedState = React.useCallback((value: boolean) => {
|
|
||||||
hasLoadedAppsRef.current = value;
|
|
||||||
setHasLoadedApps(value);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const isDesktopLocal = isTauriShell() && isDesktopLocalOriginActive();
|
const isDesktopLocal = isTauriShell() && isDesktopLocalOriginActive();
|
||||||
|
|
||||||
const applyInstalledApps = React.useCallback((installed: InstalledDesktopAppInfo[]) => {
|
|
||||||
if (installed.length === 0) {
|
|
||||||
setAvailableApps(getAlwaysAvailableApps());
|
|
||||||
setLoadedState(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowed = new Set(installed.map((app) => app.name));
|
|
||||||
const iconMap = new Map(installed.map((app) => [app.name, app.iconDataUrl ?? undefined]));
|
|
||||||
const filtered = OPEN_IN_APP_OPTIONS.filter(
|
|
||||||
(app) => allowed.has(app.appName) || ALWAYS_AVAILABLE_APP_IDS.has(app.id)
|
|
||||||
);
|
|
||||||
const withIcons = filtered.map((app) => ({
|
|
||||||
...app,
|
|
||||||
iconDataUrl: iconMap.get(app.appName),
|
|
||||||
}));
|
|
||||||
setAvailableApps(withIcons);
|
|
||||||
setLoadedState(true);
|
|
||||||
}, [setLoadedState]);
|
|
||||||
|
|
||||||
const loadInstalledApps = React.useCallback(async (force?: boolean) => {
|
|
||||||
if (isLoadingRef.current) return;
|
|
||||||
if (hasLoadedApps && !force) return;
|
|
||||||
const appNames = OPEN_IN_APP_OPTIONS.map((app) => app.appName);
|
|
||||||
if (retryTimeoutRef.current) {
|
|
||||||
clearTimeout(retryTimeoutRef.current);
|
|
||||||
retryTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
if (force) {
|
|
||||||
setLoadedState(false);
|
|
||||||
}
|
|
||||||
isLoadingRef.current = true;
|
|
||||||
setIsScanning(true);
|
|
||||||
keepScanningRef.current = false;
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
apps: installed,
|
|
||||||
success,
|
|
||||||
hasCache,
|
|
||||||
isCacheStale: nextCacheStale,
|
|
||||||
} = await fetchDesktopInstalledApps(appNames, force);
|
|
||||||
if (!isMountedRef.current) return;
|
|
||||||
setIsCacheStale(hasCache ? nextCacheStale : false);
|
|
||||||
applyInstalledApps(installed);
|
|
||||||
if (success) {
|
|
||||||
if (!hasCache && installed.length === 0 && retryAttemptRef.current < 3) {
|
|
||||||
const delays = [800, 1600, 3200];
|
|
||||||
const delay = delays[retryAttemptRef.current] ?? 3200;
|
|
||||||
retryAttemptRef.current += 1;
|
|
||||||
keepScanningRef.current = true;
|
|
||||||
retryTimeoutRef.current = setTimeout(() => {
|
|
||||||
void loadInstalledApps();
|
|
||||||
}, delay);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
retryAttemptRef.current = 0;
|
|
||||||
keepScanningRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (retryAttemptRef.current < 3) {
|
|
||||||
const delays = [1000, 3000, 7000];
|
|
||||||
const delay = delays[retryAttemptRef.current] ?? 7000;
|
|
||||||
retryAttemptRef.current += 1;
|
|
||||||
keepScanningRef.current = true;
|
|
||||||
retryTimeoutRef.current = setTimeout(() => {
|
|
||||||
void loadInstalledApps();
|
|
||||||
}, delay);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isLoadingRef.current = false;
|
|
||||||
if (!keepScanningRef.current) {
|
|
||||||
setIsScanning(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [applyInstalledApps, hasLoadedApps, setLoadedState]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!isDesktopLocal) return;
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
void loadInstalledApps();
|
|
||||||
const handler = () => {
|
|
||||||
void loadInstalledApps();
|
|
||||||
};
|
|
||||||
window.addEventListener('openchamber:app-ready', handler);
|
|
||||||
const updateHandler = (event: Event) => {
|
|
||||||
const detail = (event as CustomEvent<InstalledDesktopAppInfo[]>).detail;
|
|
||||||
if (Array.isArray(detail)) {
|
|
||||||
retryAttemptRef.current = 3;
|
|
||||||
keepScanningRef.current = false;
|
|
||||||
setIsScanning(false);
|
|
||||||
setIsCacheStale(false);
|
|
||||||
applyInstalledApps(detail);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener('openchamber:installed-apps-updated', updateHandler);
|
|
||||||
const flag = (window as unknown as { __openchamberAppReady?: boolean }).__openchamberAppReady;
|
|
||||||
if (flag) {
|
|
||||||
void loadInstalledApps();
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('openchamber:app-ready', handler);
|
|
||||||
window.removeEventListener('openchamber:installed-apps-updated', updateHandler);
|
|
||||||
};
|
|
||||||
}, [applyInstalledApps, isDesktopLocal, loadInstalledApps]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (!isDesktopLocal) return;
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const fallbackTimer = window.setTimeout(() => {
|
|
||||||
if (!hasLoadedAppsRef.current) {
|
|
||||||
void loadInstalledApps();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
return () => window.clearTimeout(fallbackTimer);
|
|
||||||
}, [isDesktopLocal, loadInstalledApps]);
|
|
||||||
|
|
||||||
const selectedApp = React.useMemo(() => {
|
const selectedApp = React.useMemo(() => {
|
||||||
const known = OPEN_IN_APP_OPTIONS.find((app) => app.id === selectedAppId)
|
const known = availableApps.find((app) => app.id === selectedAppId)
|
||||||
?? OPEN_IN_APP_OPTIONS.find((app) => app.id === DEFAULT_OPEN_IN_APP_ID)
|
?? availableApps.find((app) => app.id === DEFAULT_OPEN_IN_APP_ID)
|
||||||
?? OPEN_IN_APP_OPTIONS[0];
|
?? availableApps[0]
|
||||||
|
?? OPEN_IN_APPS[0];
|
||||||
if (known) {
|
if (known) {
|
||||||
const iconDataUrl = availableApps.find((app) => app.appName === known.appName)?.iconDataUrl;
|
return withFallbackIcon(known);
|
||||||
return iconDataUrl ? { ...known, iconDataUrl } : known;
|
|
||||||
}
|
}
|
||||||
return availableApps[0];
|
return withFallbackIcon(OPEN_IN_APPS[0]);
|
||||||
}, [availableApps, selectedAppId]);
|
}, [availableApps, selectedAppId]);
|
||||||
|
|
||||||
if (!isDesktopLocal || !directory) {
|
if (!isDesktopLocal || !directory) {
|
||||||
@@ -283,11 +115,7 @@ export const OpenInAppButton = ({ directory, activeFilePath, className }: OpenIn
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = async (app: OpenInAppOption) => {
|
const handleSelect = async (app: OpenInAppOption) => {
|
||||||
setSelectedAppId(app.id);
|
await selectApp(app.id);
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.localStorage.setItem('openInAppId', app.id);
|
|
||||||
}
|
|
||||||
await updateDesktopSettings({ openInAppId: app.id });
|
|
||||||
await handleOpen(app);
|
await handleOpen(app);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -350,23 +178,26 @@ export const OpenInAppButton = ({ directory, activeFilePath, className }: OpenIn
|
|||||||
<span className="typography-ui-label text-foreground">Copy Path</span>
|
<span className="typography-ui-label text-foreground">Copy Path</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{availableApps.map((app) => (
|
{availableApps.map((app) => {
|
||||||
<DropdownMenuItem
|
const appWithFallback = withFallbackIcon(app);
|
||||||
key={app.id}
|
return (
|
||||||
className="flex items-center gap-2"
|
<DropdownMenuItem
|
||||||
onClick={() => void handleSelect(app)}
|
key={app.id}
|
||||||
>
|
className="flex items-center gap-2"
|
||||||
<AppIcon
|
onClick={() => void handleSelect(app)}
|
||||||
label={app.label}
|
>
|
||||||
iconDataUrl={app.iconDataUrl}
|
<AppIcon
|
||||||
fallbackIconDataUrl={app.fallbackIconDataUrl}
|
label={app.label}
|
||||||
/>
|
iconDataUrl={app.iconDataUrl}
|
||||||
<span className="typography-ui-label text-foreground">{app.label}</span>
|
fallbackIconDataUrl={appWithFallback.fallbackIconDataUrl}
|
||||||
{selectedApp.id === app.id ? (
|
/>
|
||||||
<RiCheckLine className="ml-auto h-4 w-4 text-primary" />
|
<span className="typography-ui-label text-foreground">{app.label}</span>
|
||||||
) : null}
|
{selectedApp.id === app.id ? (
|
||||||
</DropdownMenuItem>
|
<RiCheckLine className="ml-auto h-4 w-4 text-primary" />
|
||||||
))}
|
) : null}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{isCacheStale ? (
|
{isCacheStale ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
RiDeleteBinLine,
|
RiDeleteBinLine,
|
||||||
RiEditLine,
|
RiEditLine,
|
||||||
RiFileCopyLine,
|
RiFileCopyLine,
|
||||||
RiFileTransferFill,
|
RiFileTransferLine,
|
||||||
} from '@remixicon/react';
|
} from '@remixicon/react';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
@@ -75,7 +75,8 @@ import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
|||||||
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
||||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||||
import { openDesktopPath, openDesktopProjectInApp } from '@/lib/desktop';
|
import { openDesktopPath, openDesktopProjectInApp } from '@/lib/desktop';
|
||||||
import { getDefaultOpenInApp, getOpenInAppById, OPEN_DIRECTORY_APP_IDS, type OpenInApp } from '@/lib/openInApps';
|
import { OPEN_DIRECTORY_APP_IDS } from '@/lib/openInApps';
|
||||||
|
import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore';
|
||||||
|
|
||||||
type FileNode = {
|
type FileNode = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -90,15 +91,6 @@ type SelectedLineRange = {
|
|||||||
end: number;
|
end: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSelectedOpenInApp = (): OpenInApp => {
|
|
||||||
const stored = typeof window !== 'undefined' ? window.localStorage.getItem('openInAppId') : null;
|
|
||||||
const selected = getOpenInAppById(stored);
|
|
||||||
if (selected) {
|
|
||||||
return selected;
|
|
||||||
}
|
|
||||||
return getDefaultOpenInApp();
|
|
||||||
};
|
|
||||||
|
|
||||||
const getParentDirectoryPath = (path: string): string => {
|
const getParentDirectoryPath = (path: string): string => {
|
||||||
const normalized = normalizePath(path);
|
const normalized = normalizePath(path);
|
||||||
if (!normalized) return '';
|
if (!normalized) return '';
|
||||||
@@ -121,6 +113,33 @@ const getParentDirectoryPath = (path: string): string => {
|
|||||||
return parent;
|
return parent;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OpenInAppListIcon = ({ label, iconDataUrl }: { label: string; iconDataUrl?: string }) => {
|
||||||
|
const [failed, setFailed] = React.useState(false);
|
||||||
|
const initial = label.trim().slice(0, 1).toUpperCase() || '?';
|
||||||
|
|
||||||
|
if (iconDataUrl && !failed) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={iconDataUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-4 w-4 rounded-sm"
|
||||||
|
onError={() => setFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 rounded-sm flex items-center justify-center',
|
||||||
|
'bg-[var(--surface-muted)] text-[9px] font-medium text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const sortNodes = (items: FileNode[]) =>
|
const sortNodes = (items: FileNode[]) =>
|
||||||
items.slice().sort((a, b) => {
|
items.slice().sort((a, b) => {
|
||||||
if (a.type !== b.type) {
|
if (a.type !== b.type) {
|
||||||
@@ -566,6 +585,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
const canRename = Boolean(files.rename);
|
const canRename = Boolean(files.rename);
|
||||||
const canDelete = Boolean(files.delete);
|
const canDelete = Boolean(files.delete);
|
||||||
const canReveal = Boolean(files.revealPath);
|
const canReveal = Boolean(files.revealPath);
|
||||||
|
const openInApps = useOpenInAppsStore((state) => state.availableApps);
|
||||||
|
const openInCacheStale = useOpenInAppsStore((state) => state.isCacheStale);
|
||||||
|
const initializeOpenInApps = useOpenInAppsStore((state) => state.initialize);
|
||||||
|
const loadOpenInApps = useOpenInAppsStore((state) => state.loadInstalledApps);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
initializeOpenInApps();
|
||||||
|
}, [initializeOpenInApps]);
|
||||||
|
|
||||||
const handleRevealPath = React.useCallback((targetPath: string) => {
|
const handleRevealPath = React.useCallback((targetPath: string) => {
|
||||||
if (!files.revealPath) return;
|
if (!files.revealPath) return;
|
||||||
@@ -574,35 +601,34 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
});
|
});
|
||||||
}, [files]);
|
}, [files]);
|
||||||
|
|
||||||
const handleOpenInSelectedApp = React.useCallback(async () => {
|
const handleOpenInApp = React.useCallback(async (app: { id: string; appName: string }) => {
|
||||||
if (!selectedFile?.path || !root) {
|
if (!selectedFile?.path || !root) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedApp = getSelectedOpenInApp();
|
|
||||||
const fileDirectory = getParentDirectoryPath(selectedFile.path) || root;
|
const fileDirectory = getParentDirectoryPath(selectedFile.path) || root;
|
||||||
|
|
||||||
if (OPEN_DIRECTORY_APP_IDS.has(selectedApp.id)) {
|
if (OPEN_DIRECTORY_APP_IDS.has(app.id)) {
|
||||||
const openedDirectory = await openDesktopPath(fileDirectory, selectedApp.appName);
|
const openedDirectory = await openDesktopPath(fileDirectory, app.appName);
|
||||||
if (!openedDirectory) {
|
if (!openedDirectory) {
|
||||||
toast.error(`Failed to open in ${selectedApp.appName}`);
|
toast.error(`Failed to open in ${app.appName}`);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const openedInApp = await openDesktopProjectInApp(root, selectedApp.id, selectedApp.appName, selectedFile.path);
|
const openedInApp = await openDesktopProjectInApp(root, app.id, app.appName, selectedFile.path);
|
||||||
if (openedInApp) {
|
if (openedInApp) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const openedFile = await openDesktopPath(selectedFile.path, selectedApp.appName);
|
const openedFile = await openDesktopPath(selectedFile.path, app.appName);
|
||||||
if (openedFile) {
|
if (openedFile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const openedDirectory = await openDesktopPath(fileDirectory, selectedApp.appName);
|
const openedDirectory = await openDesktopPath(fileDirectory, app.appName);
|
||||||
if (!openedDirectory) {
|
if (!openedDirectory) {
|
||||||
toast.error(`Failed to open in ${selectedApp.appName}`);
|
toast.error(`Failed to open in ${app.appName}`);
|
||||||
}
|
}
|
||||||
}, [root, selectedFile?.path]);
|
}, [root, selectedFile?.path]);
|
||||||
|
|
||||||
@@ -2256,16 +2282,40 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<DropdownMenu>
|
||||||
variant="ghost"
|
<DropdownMenuTrigger asChild>
|
||||||
size="sm"
|
<Button
|
||||||
onClick={() => void handleOpenInSelectedApp()}
|
variant="ghost"
|
||||||
className="h-5 w-5 p-0 text-muted-foreground opacity-70 hover:opacity-100"
|
size="sm"
|
||||||
title="Open in selected app"
|
className="h-5 w-5 p-0 text-muted-foreground opacity-70 hover:opacity-100"
|
||||||
aria-label="Open in selected app"
|
title="Open in desktop app"
|
||||||
>
|
aria-label="Open in desktop app"
|
||||||
<RiFileTransferFill className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<RiFileTransferLine className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56 max-h-[70vh] overflow-y-auto">
|
||||||
|
{openInApps.map((app) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={app.id}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => void handleOpenInApp(app)}
|
||||||
|
>
|
||||||
|
<OpenInAppListIcon label={app.label} iconDataUrl={app.iconDataUrl} />
|
||||||
|
<span className="typography-ui-label text-foreground">{app.label}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
{openInCacheStale ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => void loadOpenInApps(true)}
|
||||||
|
>
|
||||||
|
<RiRefreshLine className="h-4 w-4" />
|
||||||
|
<span className="typography-ui-label text-foreground">Refresh Apps</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
{canEdit && !isSelectedImage && (
|
{canEdit && !isSelectedImage && (
|
||||||
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
||||||
@@ -2693,16 +2743,40 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<DropdownMenu>
|
||||||
variant="ghost"
|
<DropdownMenuTrigger asChild>
|
||||||
size="sm"
|
<Button
|
||||||
onClick={() => void handleOpenInSelectedApp()}
|
variant="ghost"
|
||||||
className="h-6 w-6 p-0 text-muted-foreground opacity-70 hover:opacity-100"
|
size="sm"
|
||||||
title="Open in selected app"
|
className="h-6 w-6 p-0 text-muted-foreground opacity-70 hover:opacity-100"
|
||||||
aria-label="Open in selected app"
|
title="Open in desktop app"
|
||||||
>
|
aria-label="Open in desktop app"
|
||||||
<RiFileTransferFill className="h-4 w-4" />
|
>
|
||||||
</Button>
|
<RiFileTransferLine className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56 max-h-[70vh] overflow-y-auto">
|
||||||
|
{openInApps.map((app) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={app.id}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => void handleOpenInApp(app)}
|
||||||
|
>
|
||||||
|
<OpenInAppListIcon label={app.label} iconDataUrl={app.iconDataUrl} />
|
||||||
|
<span className="typography-ui-label text-foreground">{app.label}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
{openInCacheStale ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
onClick={() => void loadOpenInApps(true)}
|
||||||
|
>
|
||||||
|
<RiRefreshLine className="h-4 w-4" />
|
||||||
|
<span className="typography-ui-label text-foreground">Refresh Apps</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
{canEdit && !isSelectedImage && (
|
{canEdit && !isSelectedImage && (
|
||||||
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const OPEN_IN_APPS: OpenInApp[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_OPEN_IN_APP_ID = 'finder';
|
export const DEFAULT_OPEN_IN_APP_ID = 'finder';
|
||||||
|
export const OPEN_IN_ALWAYS_AVAILABLE_APP_IDS = new Set(['finder', 'terminal']);
|
||||||
export const OPEN_DIRECTORY_APP_IDS = new Set(['finder', 'terminal', 'iterm2', 'ghostty']);
|
export const OPEN_DIRECTORY_APP_IDS = new Set(['finder', 'terminal', 'iterm2', 'ghostty']);
|
||||||
|
|
||||||
export const getOpenInAppById = (id: string | null | undefined): OpenInApp | null => {
|
export const getOpenInAppById = (id: string | null | undefined): OpenInApp | null => {
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isTauriShell, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop';
|
||||||
|
import { OPEN_IN_APPS, DEFAULT_OPEN_IN_APP_ID, OPEN_IN_ALWAYS_AVAILABLE_APP_IDS, getOpenInAppById, type OpenInApp } from '@/lib/openInApps';
|
||||||
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
|
|
||||||
|
export type OpenInAppOption = OpenInApp & {
|
||||||
|
iconDataUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenInAppsState = {
|
||||||
|
selectedAppId: string;
|
||||||
|
availableApps: OpenInAppOption[];
|
||||||
|
hasLoadedApps: boolean;
|
||||||
|
isCacheStale: boolean;
|
||||||
|
isScanning: boolean;
|
||||||
|
initialize: () => void;
|
||||||
|
loadInstalledApps: (force?: boolean) => Promise<void>;
|
||||||
|
selectApp: (appId: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAlwaysAvailableApps = (): OpenInAppOption[] => {
|
||||||
|
return OPEN_IN_APPS
|
||||||
|
.filter((app) => OPEN_IN_ALWAYS_AVAILABLE_APP_IDS.has(app.id))
|
||||||
|
.map((app) => ({ ...app }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStoredAppId = (): string => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return DEFAULT_OPEN_IN_APP_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = window.localStorage.getItem('openInAppId');
|
||||||
|
if (stored && getOpenInAppById(stored)) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DEFAULT_OPEN_IN_APP_ID;
|
||||||
|
};
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
let loading = false;
|
||||||
|
let keepScanning = false;
|
||||||
|
let retryAttempt = 0;
|
||||||
|
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const clearRetryTimeout = () => {
|
||||||
|
if (retryTimeout) {
|
||||||
|
clearTimeout(retryTimeout);
|
||||||
|
retryTimeout = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOpenInAppsStore = create<OpenInAppsState>()((set, get) => ({
|
||||||
|
selectedAppId: getStoredAppId(),
|
||||||
|
availableApps: getAlwaysAvailableApps(),
|
||||||
|
hasLoadedApps: false,
|
||||||
|
isCacheStale: false,
|
||||||
|
isScanning: false,
|
||||||
|
|
||||||
|
initialize: () => {
|
||||||
|
if (initialized || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
|
const applyInstalledApps = (installed: InstalledDesktopAppInfo[]) => {
|
||||||
|
if (!Array.isArray(installed) || installed.length === 0) {
|
||||||
|
set({ availableApps: getAlwaysAvailableApps(), hasLoadedApps: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowed = new Set(installed.map((app) => app.name));
|
||||||
|
const iconMap = new Map(installed.map((app) => [app.name, app.iconDataUrl ?? undefined]));
|
||||||
|
|
||||||
|
const filtered = OPEN_IN_APPS.filter(
|
||||||
|
(app) => allowed.has(app.appName) || OPEN_IN_ALWAYS_AVAILABLE_APP_IDS.has(app.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const withIcons = filtered.map((app) => ({
|
||||||
|
...app,
|
||||||
|
iconDataUrl: iconMap.get(app.appName),
|
||||||
|
}));
|
||||||
|
|
||||||
|
set({ availableApps: withIcons, hasLoadedApps: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadInstalledApps = async (force?: boolean) => {
|
||||||
|
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = get();
|
||||||
|
if (state.hasLoadedApps && !force) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appNames = OPEN_IN_APPS.map((app) => app.appName);
|
||||||
|
clearRetryTimeout();
|
||||||
|
|
||||||
|
if (force) {
|
||||||
|
set({ hasLoadedApps: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
keepScanning = false;
|
||||||
|
set({ isScanning: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
apps: installed,
|
||||||
|
success,
|
||||||
|
hasCache,
|
||||||
|
isCacheStale,
|
||||||
|
} = await fetchDesktopInstalledApps(appNames, force);
|
||||||
|
|
||||||
|
set({ isCacheStale: hasCache ? isCacheStale : false });
|
||||||
|
applyInstalledApps(installed);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
if (!hasCache && installed.length === 0 && retryAttempt < 3) {
|
||||||
|
const delays = [800, 1600, 3200];
|
||||||
|
const delay = delays[retryAttempt] ?? 3200;
|
||||||
|
retryAttempt += 1;
|
||||||
|
keepScanning = true;
|
||||||
|
retryTimeout = setTimeout(() => {
|
||||||
|
void loadInstalledApps();
|
||||||
|
}, delay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
retryAttempt = 0;
|
||||||
|
keepScanning = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retryAttempt < 3) {
|
||||||
|
const delays = [1000, 3000, 7000];
|
||||||
|
const delay = delays[retryAttempt] ?? 7000;
|
||||||
|
retryAttempt += 1;
|
||||||
|
keepScanning = true;
|
||||||
|
retryTimeout = setTimeout(() => {
|
||||||
|
void loadInstalledApps();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
if (!keepScanning) {
|
||||||
|
set({ isScanning: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadInstalledApps();
|
||||||
|
|
||||||
|
const settingsHandler = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<DesktopSettings>).detail;
|
||||||
|
const nextId = detail
|
||||||
|
&& typeof detail.openInAppId === 'string'
|
||||||
|
&& detail.openInAppId.length > 0
|
||||||
|
&& getOpenInAppById(detail.openInAppId)
|
||||||
|
? detail.openInAppId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!nextId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem('openInAppId', nextId);
|
||||||
|
set({ selectedAppId: nextId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const appReadyHandler = () => {
|
||||||
|
void loadInstalledApps();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHandler = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<InstalledDesktopAppInfo[]>).detail;
|
||||||
|
if (!Array.isArray(detail)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
retryAttempt = 3;
|
||||||
|
keepScanning = false;
|
||||||
|
set({ isScanning: false, isCacheStale: false });
|
||||||
|
applyInstalledApps(detail);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('openchamber:settings-synced', settingsHandler);
|
||||||
|
window.addEventListener('openchamber:app-ready', appReadyHandler);
|
||||||
|
window.addEventListener('openchamber:installed-apps-updated', updateHandler);
|
||||||
|
|
||||||
|
const appReady = (window as unknown as { __openchamberAppReady?: boolean }).__openchamberAppReady;
|
||||||
|
if (appReady) {
|
||||||
|
void loadInstalledApps();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!get().hasLoadedApps) {
|
||||||
|
void loadInstalledApps();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
},
|
||||||
|
|
||||||
|
loadInstalledApps: async (force?: boolean) => {
|
||||||
|
if (!initialized) {
|
||||||
|
get().initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = get();
|
||||||
|
if (state.hasLoadedApps && !force) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const appNames = OPEN_IN_APPS.map((app) => app.appName);
|
||||||
|
clearRetryTimeout();
|
||||||
|
|
||||||
|
if (force) {
|
||||||
|
set({ hasLoadedApps: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
keepScanning = false;
|
||||||
|
set({ isScanning: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
apps: installed,
|
||||||
|
hasCache,
|
||||||
|
isCacheStale,
|
||||||
|
} = await fetchDesktopInstalledApps(appNames, force);
|
||||||
|
|
||||||
|
const allowed = new Set(installed.map((app) => app.name));
|
||||||
|
const iconMap = new Map(installed.map((app) => [app.name, app.iconDataUrl ?? undefined]));
|
||||||
|
const filtered = OPEN_IN_APPS.filter(
|
||||||
|
(app) => allowed.has(app.appName) || OPEN_IN_ALWAYS_AVAILABLE_APP_IDS.has(app.id)
|
||||||
|
);
|
||||||
|
const withIcons = filtered.map((app) => ({
|
||||||
|
...app,
|
||||||
|
iconDataUrl: iconMap.get(app.appName),
|
||||||
|
}));
|
||||||
|
|
||||||
|
set({
|
||||||
|
availableApps: withIcons.length > 0 ? withIcons : getAlwaysAvailableApps(),
|
||||||
|
hasLoadedApps: withIcons.length > 0,
|
||||||
|
isCacheStale: hasCache ? isCacheStale : false,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
if (!keepScanning) {
|
||||||
|
set({ isScanning: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
selectApp: async (appId: string) => {
|
||||||
|
if (!getOpenInAppById(appId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ selectedAppId: appId });
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem('openInAppId', appId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateDesktopSettings({ openInAppId: appId });
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user