feat: Implement application launcher with pinned apps functionality

- Added `AppItem` struct to represent application details.
- Implemented `collect_lnk_one_level` function to gather .lnk files from specified directories.
- Created `get_apps` command to retrieve applications from the Taskbar and Start Menu.
- Developed `launch_app` command to open applications.
- Introduced `get_app_icon` command to fetch application icons in base64 format.
- Added functionality to manage pinned applications with `get_pinned_apps` and `set_pinned_apps` commands.
- Implemented system tray with toggle window functionality.
- Created UI components for displaying application icons and tiles.
- Developed hooks for managing application state, drag-and-drop reordering, and search functionality.
- Styled components for a cohesive user interface.
This commit is contained in:
vitalii.litvinchuk
2026-09-19 19:40:40 +03:00
parent 2a6f9fa817
commit 9ebe687143
16 changed files with 889 additions and 704 deletions
+112
View File
@@ -0,0 +1,112 @@
use serde::Serialize;
use std::path::Path;
#[derive(Serialize, Clone)]
pub struct AppItem {
pub name: String,
pub path: String,
pub source: String,
}
pub fn collect_lnk_one_level(dir: &Path, source: &str, apps: &mut Vec<AppItem>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
// 1) .lnk прямо в цій папці
if path.is_file()
&& path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("lnk"))
== Some(true)
{
let name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
apps.push(AppItem {
name,
path: path.to_string_lossy().to_string(),
source: source.to_string(),
});
continue;
}
// 2) один рівень глибше: Programs\SomeFolder\*.lnk
if path.is_dir() {
let Ok(sub_entries) = std::fs::read_dir(&path) else {
continue;
};
for sub in sub_entries.flatten() {
let sub_path = sub.path();
if sub_path.is_file()
&& sub_path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("lnk"))
== Some(true)
{
let name = sub_path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
apps.push(AppItem {
name,
path: sub_path.to_string_lossy().to_string(),
source: source.to_string(),
});
}
}
}
}
}
#[tauri::command]
pub fn get_apps() -> Vec<AppItem> {
let mut apps = Vec::new();
if let Some(mut path) = dirs::data_dir() {
path.push(r"Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar");
if let Ok(entries) = std::fs::read_dir(&path) {
for entry in entries.flatten() {
let file_path = entry.path();
if file_path.extension().unwrap_or_default() == "lnk" {
let name = file_path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
apps.push(AppItem {
name,
path: file_path.to_string_lossy().to_string(),
source: "Taskbar".to_string(),
});
}
}
}
}
if let Some(mut path) = dirs::data_dir() {
path.push(r"Microsoft\Windows\Start Menu\Programs");
collect_lnk_one_level(&path, "Start Menu", &mut apps);
}
if let Ok(program_data) = std::env::var("ProgramData") {
let path =
std::path::PathBuf::from(program_data).join(r"Microsoft\Windows\Start Menu\Programs");
collect_lnk_one_level(&path, "Start Menu (All)", &mut apps);
}
apps
}
#[tauri::command]
pub fn launch_app(path: String) -> Result<(), String> {
open::that(path).map_err(|e| e.to_string())
}
+28
View File
@@ -0,0 +1,28 @@
#[cfg(target_os = "windows")]
use windows_icons::{get_icon_base64_by_path_with_size, IconSize};
#[tauri::command]
pub fn get_app_icon(path: String) -> Result<String, String> {
#[cfg(target_os = "windows")]
{
let result = get_icon_base64_by_path_with_size(&path, IconSize::Large)
.or_else(|_| windows_icons::get_icon_base64_by_path(&path));
match result {
Ok(b64) => {
if b64.starts_with("data:") {
Ok(b64)
} else {
Ok(format!("data:image/png;base64,{}", b64))
}
}
Err(_) => Ok(String::new()),
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = path;
Ok(String::new())
}
}
+15 -174
View File
@@ -1,129 +1,20 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::fs;
use std::path::PathBuf;
use tauri::AppHandle;
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager,
};
mod apps;
mod icons;
mod pinned;
mod tray;
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
use windows_icons::{get_icon_base64_by_path_with_size, IconSize};
#[derive(serde::Serialize)]
pub struct AppItem {
name: String,
path: String,
source: String,
}
#[tauri::command]
fn get_apps() -> Vec<AppItem> {
let mut apps = Vec::new();
if let Some(mut path) = dirs::data_dir() {
path.push("Microsoft\\Internet Explorer\\Quick Launch\\User Pinned\\TaskBar");
if let Ok(entries) = std::fs::read_dir(&path) {
for entry in entries.flatten() {
let file_path = entry.path();
if file_path.extension().unwrap_or_default() == "lnk" {
let name = file_path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
apps.push(AppItem {
name,
path: file_path.to_string_lossy().to_string(),
source: "Taskbar".to_string(),
});
}
}
}
}
if let Some(mut path) = dirs::data_dir() {
path.push("Microsoft\\Windows\\Start Menu\\Programs");
if let Ok(entries) = std::fs::read_dir(&path) {
for entry in entries.flatten() {
let file_path = entry.path();
if file_path.extension().unwrap_or_default() == "lnk" {
let name = file_path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
apps.push(AppItem {
name,
path: file_path.to_string_lossy().to_string(),
source: "Start Menu".to_string(),
});
}
}
}
}
apps
}
#[tauri::command]
fn launch_app(path: String) -> Result<(), String> {
open::that(path).map_err(|e| e.to_string())
}
fn pinned_file(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir.join("pinned.json"))
}
#[tauri::command]
fn get_pinned_apps(app: AppHandle) -> Result<Vec<String>, String> {
let path = pinned_file(&app)?;
if !path.exists() {
return Ok(Vec::new());
}
let data = fs::read_to_string(&path).map_err(|e| e.to_string())?;
serde_json::from_str(&data).map_err(|e| e.to_string())
}
#[tauri::command]
fn set_pinned_apps(app: AppHandle, paths: Vec<String>) -> Result<(), String> {
let path = pinned_file(&app)?;
let data = serde_json::to_string_pretty(&paths).map_err(|e| e.to_string())?;
fs::write(&path, data).map_err(|e| e.to_string())
}
#[tauri::command]
fn get_app_icon(path: String) -> Result<String, String> {
#[cfg(target_os = "windows")]
{
let result = get_icon_base64_by_path_with_size(&path, IconSize::Large)
.or_else(|_| windows_icons::get_icon_base64_by_path(&path));
match result {
Ok(b64) => {
if b64.starts_with("data:") {
Ok(b64)
} else {
Ok(format!("data:image/png;base64,{}", b64))
}
}
Err(_) => Ok(String::new()),
}
}
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
get_apps,
launch_app,
get_pinned_apps,
set_pinned_apps,
get_app_icon
apps::get_apps,
apps::launch_app,
pinned::get_pinned_apps,
pinned::set_pinned_apps,
icons::get_app_icon
])
.plugin(
tauri_plugin_autostart::Builder::new()
@@ -134,18 +25,10 @@ fn main() {
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, shortcut, event| {
if event.state == ShortcutState::Pressed {
if shortcut.matches(Modifiers::ALT, Code::Space) {
if let Some(window) = app.get_webview_window("main") {
let is_visible = window.is_visible().unwrap_or(false);
if is_visible {
window.hide().unwrap();
} else {
window.show().unwrap();
window.set_focus().unwrap();
}
}
}
if event.state == ShortcutState::Pressed
&& shortcut.matches(Modifiers::ALT, Code::Space)
{
tray::toggle_window(app);
}
})
.build(),
@@ -154,49 +37,7 @@ fn main() {
let alt_space = Shortcut::new(Some(Modifiers::ALT), Code::Space);
app.global_shortcut().register(alt_space)?;
let show_i = MenuItem::with_id(app, "show", "Показати/Сховати", true, None::<&str>)?;
let quit_i = MenuItem::with_id(app, "quit", "Вийти", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_i, &quit_i])?;
TrayIconBuilder::new()
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.show_menu_on_left_click(false)
.on_tray_icon_event(|tray, event| match event {
TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} => {
let app_handle = tray.app_handle();
if let Some(window) = app_handle.get_webview_window("main") {
let is_visible = window.is_visible().unwrap_or(false);
if is_visible {
window.hide().unwrap();
} else {
window.show().unwrap();
window.set_focus().unwrap();
}
}
}
_ => {}
})
.on_menu_event(|app_handle, event| match event.id.as_ref() {
"quit" => std::process::exit(0),
"show" => {
if let Some(window) = app_handle.get_webview_window("main") {
let is_visible = window.is_visible().unwrap_or(false);
if is_visible {
window.hide().unwrap();
} else {
window.show().unwrap();
window.set_focus().unwrap();
}
}
}
_ => {}
})
.build(app)?;
tray::setup_tray(app)?;
Ok(())
})
+26
View File
@@ -0,0 +1,26 @@
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
pub fn pinned_file(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
Ok(dir.join("pinned.json"))
}
#[tauri::command]
pub fn get_pinned_apps(app: AppHandle) -> Result<Vec<String>, String> {
let path = pinned_file(&app)?;
if !path.exists() {
return Ok(Vec::new());
}
let data = fs::read_to_string(&path).map_err(|e| e.to_string())?;
serde_json::from_str(&data).map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_pinned_apps(app: AppHandle, paths: Vec<String>) -> Result<(), String> {
let path = pinned_file(&app)?;
let data = serde_json::to_string_pretty(&paths).map_err(|e| e.to_string())?;
fs::write(&path, data).map_err(|e| e.to_string())
}
+54
View File
@@ -0,0 +1,54 @@
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager,
};
pub fn toggle_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
let is_visible = window.is_visible().unwrap_or(false);
if is_visible {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
}
}
}
pub fn setup_tray(app: &tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let show_i = MenuItem::with_id(app, "show", "Показати/Сховати", true, None::<&str>)?;
let quit_i = MenuItem::with_id(app, "quit", "Вийти", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show_i, &quit_i])?;
let default_icon = app
.default_window_icon()
.ok_or("default window icon missing")?
.clone();
TrayIconBuilder::new()
.icon(default_icon)
.menu(&menu)
.show_menu_on_left_click(false)
.on_tray_icon_event(|tray, event| match event {
TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} => {
let app_handle = tray.app_handle();
toggle_window(app_handle);
}
_ => {}
})
.on_menu_event(|app_handle, event| match event.id.as_ref() {
"quit" => std::process::exit(0),
"show" => {
toggle_window(app_handle);
}
_ => {}
})
.build(app)?;
Ok(())
}
+2 -1
View File
@@ -19,7 +19,8 @@
"decorations": false,
"transparent": true,
"center": true,
"skipTaskbar": true
"skipTaskbar": true,
"dragDropEnabled": false
}
],
"security": {
+64 -528
View File
@@ -1,179 +1,65 @@
import {
createSignal,
createMemo,
onMount,
Show,
For,
createEffect,
} from "solid-js";
import { invoke } from "@tauri-apps/api/core";
import { createEffect } from "solid-js";
import { getCurrentWindow } from "@tauri-apps/api/window";
type AppItem = {
name: string;
path: string;
source: string;
};
type View = "launcher" | "settings";
const COLS = 5;
import { COLS } from "./types";
import { styles as s } from "./styles";
import { useApps } from "./hooks/useApps";
import { useSearch } from "./hooks/useSearch";
import { useIcons } from "./hooks/useIcons";
import { useDragReorder } from "./hooks/useDragReorder";
import { LauncherView } from "./components/LauncherView";
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 {
apps,
pinnedPaths,
loading,
error,
pinnedApps,
persistPins,
togglePin,
launchApp,
} = useApps(() => 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 {
searchQuery,
selectedIndex,
setSelectedIndex,
filteredApps,
moveSelection,
handleInput,
resetSearch,
} = useSearch(apps, pinnedApps, pinnedPaths);
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 { iconCache, loadIcon } = useIcons();
const filteredApps = createMemo(() => {
const query = searchQuery().toLowerCase().trim();
const all = apps();
const pinnedSet = new Set(pinnedPaths());
const isSearchActive = () => searchQuery().trim().length > 0;
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];
});
const {
dragIndex,
dragOverIndex,
canDrag,
handleDragStart,
handleDragOver,
handleDrop,
handleDragEnd,
} = useDragReorder(pinnedPaths, persistPins, isSearchActive);
createEffect(() => {
const list = view() === "settings" ? pinnedApps() : filteredApps();
list.forEach((app) => loadIcon(app.path));
filteredApps().forEach((app) => loadIcon(app.path));
});
// Скрол до вибраного елемента
createEffect(() => {
const idx = selectedIndex();
const el = gridRef?.querySelector(`[data-index="${idx}"]`) as HTMLElement | null;
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;
@@ -205,12 +91,9 @@ function App() {
gridRef?.focus();
} else if (e.key === "Enter" && len > 0) {
e.preventDefault();
launchApp(list[selectedIndex()].path);
launchApp(list[selectedIndex()].path, resetSearch);
} 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()];
@@ -226,378 +109,31 @@ function App() {
}
};
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}
<LauncherView
inputRef={(el) => (inputRef = el)}
gridRef={(el) => (gridRef = el)}
searchQuery={searchQuery}
onInput={(e) => handleInput((e.target as HTMLInputElement).value)}
loading={loading}
error={error}
apps={filteredApps}
pinnedPaths={pinnedPaths}
selectedIndex={selectedIndex}
iconCache={iconCache}
onLaunch={(path) => launchApp(path, resetSearch)}
onSelect={setSelectedIndex}
onTogglePin={togglePin}
dragIndex={dragIndex}
dragOverIndex={dragOverIndex}
canDrag={canDrag}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
/>
</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>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { Show } from "solid-js";
import { styles as s } from "../styles";
type AppIconProps = {
src?: string;
name: string;
size?: number;
};
export function AppIcon(props: AppIconProps) {
const letter = () => (props.name[0] || "?").toUpperCase();
const size = () => props.size ?? 48;
return (
<div
style={{
...s.iconWrap,
width: `${size()}px`,
height: `${size()}px`,
}}
>
<Show
when={props.src}
fallback={
<span style={{ ...s.iconFallback, "font-size": `${size() * 0.38}px` }}>
{letter()}
</span>
}
>
<img
src={props.src}
alt=""
draggable={false}
style={{
width: `${size()}px`,
height: `${size()}px`,
"object-fit": "contain",
}}
/>
</Show>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { Show } from "solid-js";
import { AppItem } from "../types";
import { styles as s } from "../styles";
import { AppIcon } from "./AppIcon";
type AppTileProps = {
app: AppItem;
index: number;
iconSrc?: string;
isSelected: boolean;
isPinned: boolean;
isDragOver: boolean;
isDragging: boolean;
draggable: boolean;
onLaunch: (path: string) => void;
onSelect: (index: number) => void;
onTogglePin: (path: string) => void;
onDragStart: (e: DragEvent, index: number) => void;
onDragOver: (e: DragEvent, index: number) => void;
onDrop: (e: DragEvent, index: number) => void;
onDragEnd: () => void;
};
export function AppTile(props: AppTileProps) {
return (
<div
data-index={props.index}
draggable={props.draggable}
style={s.tile(
props.isSelected,
props.isPinned,
props.isDragOver,
props.isDragging
)}
onClick={() => props.onLaunch(props.app.path)}
onMouseEnter={() => props.onSelect(props.index)}
onContextMenu={(e) => {
e.preventDefault();
props.onTogglePin(props.app.path);
}}
onDragStart={(e) => props.onDragStart(e, props.index)}
onDragOver={(e) => props.onDragOver(e, props.index)}
onDrop={(e) => props.onDrop(e, props.index)}
onDragEnd={props.onDragEnd}
title={props.app.name}
>
<Show when={props.isPinned}>
<span style={s.pinBadge}>★</span>
</Show>
<AppIcon src={props.iconSrc} name={props.app.name} />
<div style={s.tileName}>{props.app.name}</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { Accessor, For, Show } from "solid-js";
import { AppItem } from "../types";
import { styles as s } from "../styles";
import { AppTile } from "./AppTile";
type LauncherViewProps = {
inputRef: (el: HTMLInputElement) => void;
gridRef: (el: HTMLDivElement) => void;
searchQuery: Accessor<string>;
onInput: (e: Event) => void;
loading: Accessor<boolean>;
error: Accessor<string | null>;
apps: Accessor<AppItem[]>;
pinnedPaths: Accessor<string[]>;
selectedIndex: Accessor<number>;
iconCache: Accessor<Record<string, string>>;
onLaunch: (path: string) => void;
onSelect: (index: number) => void;
onTogglePin: (path: string) => void;
dragIndex: Accessor<number | null>;
dragOverIndex: Accessor<number | null>;
canDrag: Accessor<boolean>;
onDragStart: (e: DragEvent, index: number) => void;
onDragOver: (e: DragEvent, index: number) => void;
onDrop: (e: DragEvent, index: number) => void;
onDragEnd: () => void;
};
export function LauncherView(props: LauncherViewProps) {
return (
<>
<div style={s.header}>
<input
ref={props.inputRef}
type="text"
placeholder="Пошук програм..."
value={props.searchQuery()}
onInput={props.onInput}
autofocus
style={s.input}
/>
</div>
<div ref={props.gridRef} style={s.grid} tabIndex={0}>
<Show when={props.loading()}>
<div style={s.empty}>Завантаження...</div>
</Show>
<Show when={!props.loading() && props.error()}>
<div style={s.empty}>Помилка: {props.error()}</div>
</Show>
<Show
when={!props.loading() && !props.error() && props.apps().length === 0}
>
<div style={s.empty}>
{props.searchQuery().trim()
? "Нічого не знайдено"
: "Немає закріплених програм.\nЗнайдіть програму і натисніть Ctrl+P"}
</div>
</Show>
<For each={props.apps()}>
{(app, index) => {
const isPinned = () => props.pinnedPaths().includes(app.path);
const isSelected = () => index() === props.selectedIndex();
const isOver = () => props.dragOverIndex() === index();
const isDragging = () => props.dragIndex() === index();
return (
<AppTile
app={app}
index={index()}
iconSrc={props.iconCache()[app.path]}
isSelected={isSelected()}
isPinned={isPinned()}
isDragOver={isOver()}
isDragging={isDragging()}
draggable={props.canDrag()}
onLaunch={props.onLaunch}
onSelect={props.onSelect}
onTogglePin={props.onTogglePin}
onDragStart={props.onDragStart}
onDragOver={props.onDragOver}
onDrop={props.onDrop}
onDragEnd={props.onDragEnd}
/>
);
}}
</For>
</div>
<div style={s.footer}>
<span>←→↑↓ · Enter · Ctrl+P / ПКМ пін</span>
<span style={{ color: "#777" }}>
Перетягування плиток для зміни порядку
</span>
</div>
</>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { createSignal, createMemo, onMount } from "solid-js";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { AppItem } from "../types";
export function useApps(onInitialFocus?: () => void) {
const [apps, setApps] = createSignal<AppItem[]>([]);
const [pinnedPaths, setPinnedPaths] = createSignal<string[]>([]);
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal<string | null>(null);
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);
if (onInitialFocus) {
queueMicrotask(onInitialFocus);
}
}
});
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 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 launchApp = async (path: string, onLaunched?: () => void) => {
try {
await invoke("launch_app", { path });
onLaunched?.();
await getCurrentWindow().hide();
} catch (e) {
console.error("Помилка запуску:", e);
}
};
return {
apps,
pinnedPaths,
loading,
error,
pinnedApps,
persistPins,
togglePin,
launchApp,
};
}
+78
View File
@@ -0,0 +1,78 @@
import { createSignal, Accessor } from "solid-js";
export function useDragReorder(
pinnedPaths: Accessor<string[]>,
persistPins: (paths: string[]) => void,
isSearchActive: Accessor<boolean>
) {
const [dragIndex, setDragIndex] = createSignal<number | null>(null);
const [dragOverIndex, setDragOverIndex] = createSignal<number | null>(null);
const canDrag = () => !isSearchActive();
const handleDragStart = (e: DragEvent, index: number) => {
if (!canDrag()) return;
setDragIndex(index);
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(index));
}
};
const handleDragOver = (e: DragEvent, index: number) => {
if (!canDrag()) return;
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = "move";
}
setDragOverIndex(index);
};
const handleDrop = (e: DragEvent, dropIndex: number) => {
if (!canDrag()) return;
e.preventDefault();
let from = dragIndex();
if (from === null && e.dataTransfer) {
const data = e.dataTransfer.getData("text/plain");
if (data !== "") {
const parsed = Number(data);
if (!Number.isNaN(parsed)) from = parsed;
}
}
setDragIndex(null);
setDragOverIndex(null);
if (from === null || from === dropIndex) return;
const list = [...pinnedPaths()];
if (
from < 0 ||
from >= list.length ||
dropIndex < 0 ||
dropIndex >= list.length
) {
return;
}
const [moved] = list.splice(from, 1);
list.splice(dropIndex, 0, moved);
persistPins(list);
};
const handleDragEnd = () => {
setDragIndex(null);
setDragOverIndex(null);
};
return {
dragIndex,
dragOverIndex,
canDrag,
handleDragStart,
handleDragOver,
handleDrop,
handleDragEnd,
};
}
+21
View File
@@ -0,0 +1,21 @@
import { createSignal } from "solid-js";
import { invoke } from "@tauri-apps/api/core";
export function useIcons() {
const [iconCache, setIconCache] = createSignal<Record<string, string>>({});
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]: "" }));
}
};
return {
iconCache,
loadIcon,
};
}
+58
View File
@@ -0,0 +1,58 @@
import { createSignal, createMemo, Accessor } from "solid-js";
import { AppItem } from "../types";
export function useSearch(
apps: Accessor<AppItem[]>,
pinnedApps: Accessor<AppItem[]>,
pinnedPaths: Accessor<string[]>
) {
const [searchQuery, setSearchQuery] = createSignal("");
const [selectedIndex, setSelectedIndex] = createSignal(0);
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];
});
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 handleInput = (value: string) => {
setSearchQuery(value);
setSelectedIndex(0);
};
const resetSearch = () => {
setSearchQuery("");
setSelectedIndex(0);
};
return {
searchQuery,
setSearchQuery,
selectedIndex,
setSelectedIndex,
filteredApps,
moveSelection,
handleInput,
resetSearch,
};
}
+149
View File
@@ -0,0 +1,149 @@
import { COLS } from "./types";
export const styles = {
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,
isDragOver: boolean = false,
isDragging: boolean = false
) =>
({
display: "flex",
"flex-direction": "column",
"align-items": "center",
"justify-content": "flex-start",
gap: "6px",
padding: "10px 6px 8px",
"border-radius": "12px",
cursor: "pointer",
background: isDragOver
? "#2a4d6f"
: selected
? "#2a4060"
: "transparent",
border: isDragOver
? "1px dashed #6bb1ff"
: selected
? "1px solid #4a9eff"
: pinned
? "1px solid #2a3a50"
: "1px solid transparent",
opacity: isDragging ? 0.4 : 1,
transition: "background 0.08s ease, border-color 0.08s ease, opacity 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,
"pointer-events": "none",
} 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",
"pointer-events": "none",
} as const,
pinBadge: {
position: "absolute",
top: "4px",
right: "4px",
"font-size": "10px",
color: "#4a9eff",
"line-height": 1,
"pointer-events": "none",
} 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,
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,
};
+7
View File
@@ -0,0 +1,7 @@
export type AppItem = {
name: string;
path: string;
source: string;
};
export const COLS = 5;