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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dev": "tsc -w & vite build --watch & electron .",
"dev:vite": "vite",
"preview": "npm run build && METALSHARP_PREVIEW=1 METALSHARP_HOME=\"$HOME/.metalsharp\" electron .",
"preview:dev": "npm run build && METALSHARP_PREVIEW=1 METALSHARP_DEV_LIBRARY=1 METALSHARP_HOME=\"$HOME/.metalsharp\" electron .",
"rust:build": "cd src-rust && cargo build --release",
"rust:dev": "cd src-rust && cargo build",
"build:all": "npm run rust:build && npm run build",
Expand Down
4 changes: 3 additions & 1 deletion app/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,8 +608,10 @@ async function createWindow(migrating = false) {
},
});

const query: Record<string, string> = uiOnly ? { theme: "developer" } : {};
if (process.env.METALSHARP_DEV_LIBRARY === "1") query["skip-to"] = "library";
mainWindow.loadFile(path.join(__dirname, "..", "renderer", "index.html"), {
query: isUiOnlyRuntime() ? { theme: "developer" } : {},
query,
});
}

Expand Down
4 changes: 3 additions & 1 deletion app/src/main/rust-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,9 @@ export class RustBridge {
resolve(true);
});
req.on("error", () => resolve(false));
req.setTimeout(1500, () => {
// Dev backends can be busy with a first-run bottle scan; don't treat a
// slow /status as dead or ensureRunning will kill and restart it forever.
req.setTimeout(this.devMode ? 10000 : 1500, () => {
req.destroy();
resolve(false);
});
Expand Down
21 changes: 16 additions & 5 deletions app/src/renderer/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const updateDismissed = ref(false);
let updatePollTimer: ReturnType<typeof setInterval> | null = null;
let installPollTimer: ReturnType<typeof setInterval> | null = null;

const { theme, toggle: toggleTheme } = useTheme();
const { theme, setTheme } = useTheme();
const toast = useToast();

const viewMap: Record<string, Component> = {
Expand Down Expand Up @@ -319,6 +319,17 @@ watch(lowPerformanceMode, (enabled) => {
onMounted(async () => {
applyLowPerformanceMode(lowPerformanceMode.value);
await checkBackend();
if (new URLSearchParams(window.location.search).get("skip-to") === "library") {
// The dev backend may still be starting (first-run bottle scan); wait for
// it before loading the library instead of racing a dead window.
for (let i = 0; i < 60 && !backendConnected.value; i++) {
await new Promise((r) => setTimeout(r, 1000));
await checkBackend();
}
await initApp();
checkForUpdates();
return;
}
const migrationMode = await getAPI().isMigrationMode?.();
if (migrationMode) {
showMigration.value = true;
Expand All @@ -345,7 +356,7 @@ onMounted(async () => {
:current-view="currentView"
:theme="theme"
@navigate="currentView = $event"
@toggle-theme="toggleTheme()"
@select-theme="setTheme($event)"
/>
<main
class="content"
Expand Down Expand Up @@ -573,9 +584,9 @@ onMounted(async () => {
}
:root[data-theme="developer"] .content {
background:
linear-gradient(118deg, rgba(185, 255, 77, 0.025), transparent 36%, rgba(0, 245, 255, 0.03) 72%, transparent),
radial-gradient(circle at 24% 16%, rgba(255, 46, 247, 0.14), transparent 34%),
radial-gradient(circle at 84% 10%, rgba(0, 245, 255, 0.11), transparent 30%), var(--bg-deep);
linear-gradient(118deg, rgba(185, 255, 77, 0.02), transparent 36%, rgba(0, 245, 255, 0.024) 72%, transparent),
radial-gradient(circle at 24% 16%, rgba(255, 46, 247, 0.11), transparent 34%),
radial-gradient(circle at 84% 10%, rgba(0, 245, 255, 0.09), transparent 30%), var(--bg-deep);
}
.content.content-glass-header {
background: transparent;
Expand Down
145 changes: 125 additions & 20 deletions app/src/renderer/components/Sidebar.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
<script setup lang="ts">
import { computed, ref, type Component } from "vue";
import IconMenu from "~icons/lucide/menu";
import IconServer from "~icons/lucide/server";
import IconLayers from "~icons/lucide/layers";
import IconFileText from "~icons/lucide/file-text";
import IconMoon from "~icons/lucide/moon";
import IconSun from "~icons/lucide/sun";
import IconSettings from "~icons/lucide/settings";
import IconTerminal from "~icons/lucide/terminal";
import type { ThemeName } from "../composables/useTheme";
import IconBone from "~icons/lucide/bone";
import IconTreePine from "~icons/lucide/tree-pine";
import IconCitrus from "~icons/lucide/citrus";
import IconSparkles from "~icons/lucide/sparkles";
import { themedNavIcon, type ThemeName } from "../composables/useTheme";

const props = defineProps<{
currentView: string;
Expand All @@ -17,27 +18,47 @@ const props = defineProps<{

const emit = defineEmits<{
navigate: [view: string];
toggleTheme: [];
selectTheme: [theme: ThemeName];
}>();

const collapsed = ref(false);
const themePickerOpen = ref(false);

const themeToggleLabel = computed(() => {
if (props.theme === "developer") return "Dev Mode";
return props.theme === "light" ? "Light Mode" : "Dark Mode";
});
interface ThemeOption {
name: ThemeName;
label: string;
icon: Component;
}

const themeOptions: ThemeOption[] = [
{ name: "dark", label: "Dark", icon: IconMoon },
{ name: "light", label: "Light", icon: IconSun },
{ name: "developer", label: "Dev Mode", icon: IconTerminal },
{ name: "skeleton", label: "Skeleton", icon: IconBone },
{ name: "forest", label: "Forest", icon: IconTreePine },
{ name: "orange-peel", label: "Orange Peel", icon: IconCitrus },
{ name: "dragonfruit", label: "Dragonfruit", icon: IconSparkles },
];

const currentThemeOption = computed(() => themeOptions.find((o) => o.name === props.theme) ?? themeOptions[0]);
const themeToggleLabel = computed(() => currentThemeOption.value.label);

function chooseTheme(name: ThemeName) {
emit("selectTheme", name);
themePickerOpen.value = false;
}

interface NavItem {
view: string;
label: string;
icon: Component;
}

const navItems: NavItem[] = [
{ view: "library", label: "Library", icon: IconServer },
{ view: "sharp-library", label: "Sharp", icon: IconLayers },
{ view: "logs", label: "Logs", icon: IconFileText },
];
const navItems = computed<NavItem[]>(() => [
{ view: "library", label: "Library", icon: themedNavIcon("library") },
{ view: "sharp-library", label: "Sharp", icon: themedNavIcon("sharp") },
{ view: "logs", label: "Logs", icon: themedNavIcon("logs") },
]);
</script>

<template>
Expand Down Expand Up @@ -69,14 +90,27 @@ const navItems: NavItem[] = [
<div class="sidebar-bottom">
<button
class="sidebar-nav-item sidebar-theme-toggle"
@click="emit('toggleTheme')"
@click="themePickerOpen = !themePickerOpen"
:title="collapsed ? themeToggleLabel : undefined"
>
<IconTerminal v-if="theme === 'developer'" class="sidebar-nav-icon" width="18" height="18" />
<IconSun v-else-if="theme === 'light'" class="sidebar-nav-icon" width="18" height="18" />
<IconMoon v-else class="sidebar-nav-icon" width="18" height="18" />
<component :is="currentThemeOption.icon" class="sidebar-nav-icon" width="18" height="18" />
<span v-if="!collapsed" class="sidebar-nav-label">{{ themeToggleLabel }}</span>
</button>
<Teleport to="body">
<div v-if="themePickerOpen" class="theme-picker-backdrop" @click="themePickerOpen = false"></div>
<div v-if="themePickerOpen" class="theme-picker-popover">
<button
v-for="option in themeOptions"
:key="option.name"
class="theme-picker-item"
:class="{ active: option.name === theme }"
@click="chooseTheme(option.name)"
>
<component :is="option.icon" class="theme-picker-icon" width="16" height="16" />
<span class="theme-picker-label">{{ option.label }}</span>
</button>
</div>
</Teleport>
<button
class="sidebar-nav-item"
:class="{ active: currentView === 'settings' }"
Expand Down Expand Up @@ -140,6 +174,22 @@ const navItems: NavItem[] = [
background-color: rgba(9, 7, 15, 0.32);
}

:global(:root[data-theme="skeleton"] .sidebar) {
background-color: rgba(19, 19, 19, 0.32);
}

:global(:root[data-theme="forest"] .sidebar) {
background-color: rgba(13, 21, 16, 0.32);
}

:global(:root[data-theme="orange-peel"] .sidebar) {
background-color: rgba(18, 11, 8, 0.32);
}

:global(:root[data-theme="dragonfruit"] .sidebar) {
background-color: rgba(26, 14, 24, 0.32);
}

:global(:root[data-low-performance="true"] .sidebar) {
background-color: var(--bg-surface);
backdrop-filter: none;
Expand Down Expand Up @@ -244,7 +294,7 @@ const navItems: NavItem[] = [
position: relative;
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
gap: 10px;
min-height: 36px;
padding: 8px 10px;
Expand All @@ -256,7 +306,7 @@ const navItems: NavItem[] = [
cursor: pointer;
transition: all var(--transition);
width: 100%;
text-align: center;
text-align: left;
font-size: 13px;
-webkit-app-region: no-drag;
white-space: nowrap;
Expand Down Expand Up @@ -390,6 +440,61 @@ const navItems: NavItem[] = [
margin-bottom: 4px;
}

.theme-picker-backdrop {
position: fixed;
inset: 0;
z-index: 89;
}

.theme-picker-popover {
position: fixed;
left: 8px;
bottom: 96px;
width: calc(var(--sidebar-width-expanded) - 16px);
z-index: 90;
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px;
background: var(--bg-card);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
box-shadow: 0 12px 32px var(--card-glow);
-webkit-app-region: no-drag;
}

.theme-picker-item {
display: flex;
align-items: center;
gap: 10px;
min-height: 32px;
padding: 6px 10px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: none;
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
text-align: left;
transition: all var(--transition);
}
.theme-picker-item:hover {
background: var(--sidebar-hover);
border-color: var(--border);
}
.theme-picker-item.active {
background: var(--accent-glow);
border-color: var(--accent-dim);
color: var(--accent);
}

.theme-picker-icon {
flex-shrink: 0;
width: 16px;
height: 16px;
}

.sidebar-bottom {
padding: 8px;
border-top: 1px solid var(--border);
Expand Down
88 changes: 84 additions & 4 deletions app/src/renderer/composables/useTheme.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,32 @@
import { ref, watch } from "vue";
import { type Component, ref, watch } from "vue";
import IconAxe from "~icons/lucide/axe";
import IconBinoculars from "~icons/lucide/binoculars";
import IconBone from "~icons/lucide/bone";
import IconBraces from "~icons/lucide/braces";
import IconCitrus from "~icons/lucide/citrus";
import IconClipboardList from "~icons/lucide/clipboard-list";
import IconCpu from "~icons/lucide/cpu";
import IconCrosshair from "~icons/lucide/crosshair";
import IconFileCode from "~icons/lucide/file-code";
import IconFileText from "~icons/lucide/file-text";
import IconFlame from "~icons/lucide/flame";
import IconGem from "~icons/lucide/gem";
import IconGrape from "~icons/lucide/grape";
import IconLayers from "~icons/lucide/layers";
import IconLeaf from "~icons/lucide/leaf";
import IconRefreshCcw from "~icons/lucide/refresh-ccw";
import IconRocket from "~icons/lucide/rocket";
import IconRotateCw from "~icons/lucide/rotate-cw";
import IconScroll from "~icons/lucide/scroll";
import IconScrollText from "~icons/lucide/scroll-text";
import IconServer from "~icons/lucide/server";
import IconSkull from "~icons/lucide/skull";
import IconTrees from "~icons/lucide/trees";
import IconZap from "~icons/lucide/zap";

export type ThemeName = "dark" | "light" | "developer";
export type ThemeName = "dark" | "light" | "developer" | "skeleton" | "forest" | "orange-peel" | "dragonfruit";

const themes: ThemeName[] = ["dark", "light", "developer"];
export const themes: ThemeName[] = ["dark", "light", "developer", "skeleton", "forest", "orange-peel", "dragonfruit"];

function readSavedTheme(): ThemeName {
const requested = new URLSearchParams(window.location.search).get("theme");
Expand All @@ -24,11 +48,67 @@ document.documentElement.dataset.theme = theme.value;
document.body.classList.toggle("light", theme.value === "light");
document.body.classList.toggle("developer", theme.value === "developer");

export type NavIconKey = "library" | "sharp" | "logs" | "refresh" | "steam";

const defaultNavIcons: Record<NavIconKey, Component> = {
library: IconServer,
sharp: IconLayers,
logs: IconFileText,
refresh: IconRefreshCcw,
steam: IconCrosshair,
};

const themeNavIcons: Partial<Record<ThemeName, Partial<Record<NavIconKey, Component>>>> = {
developer: {
library: IconCpu,
sharp: IconBraces,
logs: IconFileCode,
refresh: IconRotateCw,
steam: IconRocket,
},
skeleton: {
library: IconSkull,
sharp: IconBone,
logs: IconScroll,
refresh: IconRotateCw,
steam: IconAxe,
},
forest: {
library: IconTrees,
sharp: IconLeaf,
logs: IconScroll,
refresh: IconRotateCw,
steam: IconBinoculars,
},
"orange-peel": {
library: IconFlame,
sharp: IconCitrus,
logs: IconClipboardList,
refresh: IconRotateCw,
steam: IconZap,
},
dragonfruit: {
library: IconZap,
sharp: IconGem,
logs: IconScrollText,
refresh: IconRotateCw,
steam: IconGrape,
},
};

export function themedNavIcon(key: NavIconKey): Component {
return themeNavIcons[theme.value]?.[key] ?? defaultNavIcons[key];
}

export function useTheme() {
function toggle() {
const currentIndex = themes.indexOf(theme.value);
theme.value = themes[(currentIndex + 1) % themes.length];
}

return { theme, toggle };
function setTheme(name: ThemeName) {
if (themes.includes(name)) theme.value = name;
}

return { theme, toggle, setTheme };
}
Loading
Loading