Files
Tauri-Launcher/src/App.tsx
T
2026-09-18 09:42:16 +03:00

604 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AppItem[]>([]);
const [pinnedPaths, setPinnedPaths] = createSignal<string[]>([]);
const [searchQuery, setSearchQuery] = createSignal("");
const [selectedIndex, setSelectedIndex] = createSignal(0);
const [view, setView] = createSignal<View>("launcher");
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal<string | null>(null);
const [iconCache, setIconCache] = createSignal<Record<string, string>>({});
const [dragIndex, setDragIndex] = createSignal<number | null>(null);
const [dragOverIndex, setDragOverIndex] = createSignal<number | null>(null);
let inputRef: HTMLInputElement | undefined;
let gridRef: HTMLDivElement | undefined;
onMount(async () => {
try {
const [loadedApps, pinned] = await Promise.all([
invoke<AppItem[]>("get_apps"),
invoke<string[]>("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<string>("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 (
<div
style={{
...s.iconWrap,
width: `${size}px`,
height: `${size}px`,
}}
>
<Show
when={src()}
fallback={
<span style={{ ...s.iconFallback, "font-size": `${size * 0.38}px` }}>
{letter()}
</span>
}
>
<img
src={src()}
alt=""
draggable={false}
style={{
width: `${size}px`,
height: `${size}px`,
"object-fit": "contain",
}}
/>
</Show>
</div>
);
};
return (
<div style={s.root} onKeyDown={handleKeyDown} tabIndex={-1}>
{/* ========== LAUNCHER ========== */}
<Show when={view() === "launcher"}>
<div style={s.header}>
<input
ref={inputRef}
type="text"
placeholder="Пошук програм..."
value={searchQuery()}
onInput={handleInput}
autofocus
style={s.input}
/>
</div>
<div ref={gridRef} style={s.grid} tabIndex={0}>
<Show when={loading()}>
<div style={s.empty}>Завантаження...</div>
</Show>
<Show when={!loading() && error()}>
<div style={s.empty}>Помилка: {error()}</div>
</Show>
<Show when={!loading() && !error() && filteredApps().length === 0}>
<div style={s.empty}>
{searchQuery().trim()
? "Нічого не знайдено"
: "Немає закріплених програм.\nЗнайдіть програму і натисніть Ctrl+P"}
</div>
</Show>
<For each={filteredApps()}>
{(app, index) => {
const isPinned = () => pinnedPaths().includes(app.path);
const isSelected = () => index() === selectedIndex();
return (
<div
data-index={index()}
style={s.tile(isSelected(), isPinned())}
onClick={() => launchApp(app.path)}
onMouseEnter={() => setSelectedIndex(index())}
onContextMenu={(e) => {
e.preventDefault();
togglePin(app.path);
}}
title={app.name}
>
<Show when={isPinned()}>
<span style={s.pinBadge}>★</span>
</Show>
<AppIcon path={app.path} name={app.name} />
<div style={s.tileName}>{app.name}</div>
</div>
);
}}
</For>
</div>
<div style={s.footer}>
<span>←→↑↓ · Enter · Ctrl+P пін · ПКМ пін</span>
<span
style={{ cursor: "pointer", color: "#777" }}
onClick={() => setView("settings")}
>
Ctrl+, налаштування
</span>
</div>
</Show>
{/* ========== SETTINGS ========== */}
<Show when={view() === "settings"}>
<div style={s.settingsHeader}>
<button style={s.backBtn} onClick={() => setView("launcher")}>
← Назад
</button>
<span style={{ "font-weight": 600, "font-size": "14px" }}>
Закріплені
</span>
<div style={{ width: "64px" }} />
</div>
<div style={s.sectionTitle}>
Перетягни для зміни порядку · {pinnedApps().length}
</div>
<ul style={s.settingsList}>
<Show when={pinnedApps().length === 0}>
<div style={s.empty}>
Немає закріплених.
{"\n"}
У пошуку натисни Ctrl+P або ПКМ на плитці.
</div>
</Show>
<For each={pinnedApps()}>
{(app, index) => {
const isOver = () => dragOverIndex() === index();
return (
<li
draggable={true}
style={s.settingsItem(isOver())}
onDragStart={() => onDragStart(index())}
onDragOver={(e) => onDragOver(e, index())}
onDrop={() => onDrop(index())}
onDragEnd={onDragEnd}
>
<span style={s.dragHandle}>⠿</span>
<AppIcon path={app.path} name={app.name} size={28} />
<div style={{ flex: 1, "min-width": 0 }}>
<div style={s.settingsName}>{app.name}</div>
<div style={s.settingsSource}>{app.source}</div>
</div>
<button
tabIndex={-1}
style={{ ...s.iconBtn, color: "#c55" }}
title="Видалити"
onClick={() => removePinned(app.path)}
>
✕
</button>
</li>
);
}}
</For>
</ul>
<div style={s.footer}>
<span>Esc — назад</span>
</div>
</Show>
</div>
);
}
export default App;