import { createSignal, createMemo, onMount, Show, For, createEffect, } from "solid-js"; import { invoke } from "@tauri-apps/api/core"; import { getCurrentWindow } from "@tauri-apps/api/window"; type AppItem = { name: string; path: string; source: string; }; type View = "launcher" | "settings"; const COLS = 5; function App() { const [apps, setApps] = createSignal([]); const [pinnedPaths, setPinnedPaths] = createSignal([]); const [searchQuery, setSearchQuery] = createSignal(""); const [selectedIndex, setSelectedIndex] = createSignal(0); const [view, setView] = createSignal("launcher"); const [loading, setLoading] = createSignal(true); const [error, setError] = createSignal(null); const [iconCache, setIconCache] = createSignal>({}); const [dragIndex, setDragIndex] = createSignal(null); const [dragOverIndex, setDragOverIndex] = createSignal(null); let inputRef: HTMLInputElement | undefined; let gridRef: HTMLDivElement | undefined; onMount(async () => { try { const [loadedApps, pinned] = await Promise.all([ invoke("get_apps"), invoke("get_pinned_apps").catch(() => [] as string[]), ]); setApps(loadedApps); setPinnedPaths(pinned); } catch (e) { setError(String(e)); console.error(e); } finally { setLoading(false); queueMicrotask(() => inputRef?.focus()); } }); const loadIcon = async (path: string) => { if (iconCache()[path] !== undefined) return; try { const dataUrl = await invoke("get_app_icon", { path }); setIconCache((prev) => ({ ...prev, [path]: dataUrl || "" })); } catch { setIconCache((prev) => ({ ...prev, [path]: "" })); } }; const pinnedApps = createMemo(() => { const order = pinnedPaths(); const byPath = new Map(apps().map((a) => [a.path, a])); return order.map((p) => byPath.get(p)).filter((a): a is AppItem => !!a); }); const filteredApps = createMemo(() => { const query = searchQuery().toLowerCase().trim(); const all = apps(); const pinnedSet = new Set(pinnedPaths()); if (!query) return pinnedApps(); const matched = all.filter((app) => app.name.toLowerCase().includes(query) ); const pinnedMatched = matched.filter((a) => pinnedSet.has(a.path)); const rest = matched.filter((a) => !pinnedSet.has(a.path)); return [...pinnedMatched, ...rest]; }); createEffect(() => { const list = view() === "settings" ? pinnedApps() : filteredApps(); list.forEach((app) => loadIcon(app.path)); }); // Скрол до вибраного елемента createEffect(() => { const idx = selectedIndex(); const el = gridRef?.querySelector(`[data-index="${idx}"]`) as HTMLElement | null; el?.scrollIntoView({ block: "nearest", behavior: "smooth" }); }); const launchApp = async (path: string) => { try { await invoke("launch_app", { path }); setSearchQuery(""); setSelectedIndex(0); await getCurrentWindow().hide(); } catch (e) { console.error("Помилка запуску:", e); } }; const persistPins = async (next: string[]) => { const prev = pinnedPaths(); setPinnedPaths(next); try { await invoke("set_pinned_apps", { paths: next }); } catch (e) { console.error(e); setPinnedPaths(prev); } }; const togglePin = (path: string) => { const current = pinnedPaths(); const next = current.includes(path) ? current.filter((p) => p !== path) : [...current, path]; persistPins(next); }; const removePinned = (path: string) => { persistPins(pinnedPaths().filter((p) => p !== path)); }; const onDragStart = (index: number) => setDragIndex(index); const onDragOver = (e: DragEvent, index: number) => { e.preventDefault(); setDragOverIndex(index); }; const onDrop = (index: number) => { const from = dragIndex(); if (from === null || from === index) { setDragIndex(null); setDragOverIndex(null); return; } const list = [...pinnedPaths()]; const [item] = list.splice(from, 1); list.splice(index, 0, item); persistPins(list); setDragIndex(null); setDragOverIndex(null); }; const onDragEnd = () => { setDragIndex(null); setDragOverIndex(null); }; const moveSelection = (delta: number) => { const len = filteredApps().length; if (len === 0) return; setSelectedIndex((i) => { const next = i + delta; if (next < 0) return 0; if (next >= len) return len - 1; return next; }); }; const handleKeyDown = (e: KeyboardEvent) => { if (view() === "settings") { if (e.key === "Escape") { setView("launcher"); setSelectedIndex(0); queueMicrotask(() => inputRef?.focus()); } return; } const list = filteredApps(); const len = list.length; if (e.key === "Tab") { e.preventDefault(); if (document.activeElement === inputRef) { gridRef?.focus(); } else { inputRef?.focus(); } return; } if (e.key === "ArrowRight") { e.preventDefault(); moveSelection(1); gridRef?.focus(); } else if (e.key === "ArrowLeft") { e.preventDefault(); moveSelection(-1); gridRef?.focus(); } else if (e.key === "ArrowDown") { e.preventDefault(); moveSelection(COLS); gridRef?.focus(); } else if (e.key === "ArrowUp") { e.preventDefault(); moveSelection(-COLS); gridRef?.focus(); } else if (e.key === "Enter" && len > 0) { e.preventDefault(); launchApp(list[selectedIndex()].path); } else if (e.key === "Escape") { getCurrentWindow().hide(); } else if ((e.ctrlKey || e.metaKey) && e.key === ",") { e.preventDefault(); setView("settings"); } else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "p") { e.preventDefault(); const app = list[selectedIndex()]; if (app) togglePin(app.path); } else if ( e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && document.activeElement !== inputRef ) { inputRef?.focus(); } }; const handleInput = (e: Event) => { setSearchQuery((e.target as HTMLInputElement).value); setSelectedIndex(0); }; // ---------- стилі ---------- const s = { root: { width: "100%", height: "100vh", background: "#1a1a1a", color: "#e8e8e8", "font-family": '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', display: "flex", "flex-direction": "column", overflow: "hidden", "user-select": "none", } as const, header: { padding: "12px 12px 8px", "border-bottom": "1px solid #2e2e2e", "flex-shrink": 0, } as const, input: { width: "100%", padding: "11px 14px", "font-size": "16px", "border-radius": "10px", border: "1px solid #3a3a3a", background: "#252525", color: "#fff", outline: "none", "box-sizing": "border-box", } as const, grid: { flex: 1, "overflow-y": "auto", padding: "12px", display: "grid", "grid-template-columns": `repeat(${COLS}, 1fr)`, gap: "10px", "align-content": "start", outline: "none", } as const, tile: (selected: boolean, pinned: boolean) => ({ display: "flex", "flex-direction": "column", "align-items": "center", "justify-content": "flex-start", gap: "6px", padding: "10px 6px 8px", "border-radius": "12px", cursor: "pointer", background: selected ? "#2a4060" : "transparent", border: selected ? "1px solid #4a9eff" : pinned ? "1px solid #2a3a50" : "1px solid transparent", transition: "background 0.08s ease, border-color 0.08s ease", "min-width": 0, position: "relative", }) as const, iconWrap: { width: "48px", height: "48px", "border-radius": "10px", overflow: "hidden", background: "#2a2a2a", display: "flex", "align-items": "center", "justify-content": "center", "flex-shrink": 0, } as const, iconImg: { width: "48px", height: "48px", "object-fit": "contain", } as const, iconFallback: { "font-size": "18px", color: "#888", "font-weight": 600, } as const, tileName: { "font-size": "11px", "font-weight": 500, "text-align": "center", "line-height": 1.25, color: "#ccc", width: "100%", overflow: "hidden", "text-overflow": "ellipsis", "white-space": "nowrap", "padding-left": "2px", "padding-right": "2px", } as const, pinBadge: { position: "absolute", top: "4px", right: "4px", "font-size": "10px", color: "#4a9eff", "line-height": 1, } as const, empty: { "grid-column": `1 / -1`, padding: "40px 16px", "text-align": "center", color: "#666", "font-size": "13px", "line-height": 1.5, "white-space": "pre-line", } as const, settingsHeader: { display: "flex", "align-items": "center", "justify-content": "space-between", padding: "12px 14px", "border-bottom": "1px solid #2e2e2e", "flex-shrink": 0, } as const, backBtn: { background: "transparent", border: "1px solid #444", color: "#ccc", padding: "5px 10px", "border-radius": "6px", cursor: "pointer", "font-size": "12px", } as const, sectionTitle: { padding: "10px 14px 4px", "font-size": "11px", color: "#666", "text-transform": "uppercase", "letter-spacing": "0.06em", } as const, settingsList: { flex: 1, "overflow-y": "auto", padding: "8px", margin: 0, "list-style": "none", } as const, settingsItem: (isDragOver: boolean) => ({ display: "flex", "align-items": "center", gap: "10px", padding: "8px 10px", "border-radius": "8px", background: isDragOver ? "#2a3a4a" : "transparent", "border-left": "3px solid #4a9eff", margin: "2px 0", }) as const, settingsName: { flex: 1, "min-width": 0, "font-size": "13px", "font-weight": 500, overflow: "hidden", "text-overflow": "ellipsis", "white-space": "nowrap", } as const, settingsSource: { "font-size": "11px", color: "#777", overflow: "hidden", "text-overflow": "ellipsis", "white-space": "nowrap", } as const, iconBtn: { background: "transparent", border: "none", color: "#666", cursor: "pointer", "font-size": "14px", padding: "4px 6px", "line-height": 1, } as const, footer: { padding: "6px 12px", "border-top": "1px solid #2e2e2e", "font-size": "11px", color: "#555", display: "flex", "justify-content": "space-between", "align-items": "center", "flex-shrink": 0, } as const, dragHandle: { color: "#555", cursor: "grab", "font-size": "14px", padding: "0 4px", } as const, }; const AppIcon = (props: { path: string; name: string; size?: number }) => { const src = () => iconCache()[props.path]; const letter = () => (props.name[0] || "?").toUpperCase(); const size = props.size ?? 48; return (
{letter()} } >
); }; return (
{/* ========== LAUNCHER ========== */}
Завантаження...
Помилка: {error()}
{searchQuery().trim() ? "Нічого не знайдено" : "Немає закріплених програм.\nЗнайдіть програму і натисніть Ctrl+P"}
{(app, index) => { const isPinned = () => pinnedPaths().includes(app.path); const isSelected = () => index() === selectedIndex(); return (
launchApp(app.path)} onMouseEnter={() => setSelectedIndex(index())} onContextMenu={(e) => { e.preventDefault(); togglePin(app.path); }} title={app.name} > ★
{app.name}
); }}
←→↑↓ · Enter · Ctrl+P пін · ПКМ пін setView("settings")} > Ctrl+, налаштування
{/* ========== SETTINGS ========== */}
Закріплені
Перетягни для зміни порядку · {pinnedApps().length}
    Немає закріплених. {"\n"} У пошуку натисни Ctrl+P або ПКМ на плитці.
    {(app, index) => { const isOver = () => dragOverIndex() === index(); return (
  • onDragStart(index())} onDragOver={(e) => onDragOver(e, index())} onDrop={() => onDrop(index())} onDragEnd={onDragEnd} > ⠿
    {app.name}
    {app.source}
  • ); }}
Esc — назад
); } export default App;