Skip to content
Open
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
11 changes: 11 additions & 0 deletions src/renderer/components/ConfigCard.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<template>
<x-card
class="flex flex-row justify-between items-center p-2 py-3 my-0 w-full backdrop-blur-xl backdrop-brightness-150 bg-neutral-800/20"
:class="{ 'opacity-50 pointer-events-none': props.disabled }"
>
<div>
<div class="flex flex-row gap-2 items-center mb-2">
Expand All @@ -18,6 +19,7 @@
v-if="props.step"
type="button"
class="size-8 !p-0"
:disabled="props.disabled"
@click="() => applyStep(-props.step!)"
>
<Icon icon="mdi:minus" class="size-4"></Icon>
Expand All @@ -28,6 +30,7 @@
:min="props.min"
:max="props.max"
:value="value"
:disabled="props.disabled"
v-on:keydown="(e: any) => ensureNumericInput(e)"
@input="(e: any) => (value = Number(/^\d+$/.exec(e.target.value)![0] || props.min))"
required
Expand All @@ -36,6 +39,7 @@
v-if="props.step"
type="button"
class="size-8 !p-0"
:disabled="props.disabled"
@click="() => applyStep(props.step!)"
>
<Icon icon="mdi:plus" class="size-4"></Icon>
Expand All @@ -46,6 +50,7 @@
<template v-else-if="props.type === 'dropdown'">
<x-select
class="w-20"
:disabled="props.disabled"
@change="(e: any) => (value = e.detail.newValue)"
>
<x-menu>
Expand All @@ -58,6 +63,7 @@
<template v-else-if="props.type === 'switch'">
<x-switch
:toggled="value"
:disabled="props.disabled"
@toggle="(_: any) => { $emit('toggle'); (value = !value) }"
size="large"
/>
Expand Down Expand Up @@ -121,6 +127,11 @@ type PropsType = {
* Defines dropdown entries in case the `dropdown` type is specified.
*/
options?: any[];

/**
* If true, disables all interactive elements and applies greyed-out styling.
*/
disabled?: boolean;
};

const props = defineProps<PropsType>();
Expand Down
15 changes: 9 additions & 6 deletions src/renderer/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class WinboatVersion {
const versionNumbers = versionTags[0].split(".").map(value => {
const parsedValue = parseInt(value);

if(Number.isNaN(parsedValue)) {
if (Number.isNaN(parsedValue)) {
throw new Error(`Invalid winboat version format: '${versionToken}'`);
}

Expand Down Expand Up @@ -71,6 +71,8 @@ export type WinboatConfigObj = {
containerRuntime: ContainerRuntimes;
versionData: WinboatVersionData;
appsSortOrder: string;
favoriteApps: string[];
recentApps: Array<{ name: string, timestamp: number }>;
};

const currentVersion = new WinboatVersion(import.meta.env.VITE_APP_VERSION);
Expand All @@ -94,12 +96,14 @@ const defaultConfig: WinboatConfigObj = {
current: currentVersion
},
appsSortOrder: 'name',
favoriteApps: [],
recentApps: [],
};

export class WinboatConfig {
private static readonly configPath: string = path.join(WINBOAT_DIR, "winboat.config.json");
private static instance: WinboatConfig | null = null;

// Due to us wrapping WinboatConfig in reactive, this can't be private
configData: WinboatConfigObj = { ...defaultConfig };

Expand All @@ -112,7 +116,7 @@ export class WinboatConfig {
this.configData = WinboatConfig.readConfigObject()!;

// Set correct versionData
if(this.config.versionData.current.versionToken !== currentVersion.versionToken) {
if (this.config.versionData.current.versionToken !== currentVersion.versionToken) {
this.config.versionData.previous = this.config.versionData.current;
this.config.versionData.current = currentVersion;

Expand Down Expand Up @@ -165,7 +169,7 @@ export class WinboatConfig {
const configObjRaw = JSON.parse(rawConfig);

// Parse winboat version data
if(configObjRaw.versionData) {
if (configObjRaw.versionData) {
configObjRaw.versionData.current = new WinboatVersion(configObjRaw.versionData.current);
configObjRaw.versionData.previous = new WinboatVersion(configObjRaw.versionData.previous);
}
Expand All @@ -182,8 +186,7 @@ export class WinboatConfig {
configObj[key] = defaultConfig[key];
hasMissing = true;
console.log(
`Added missing config key: ${key} with default value: ${
JSON.stringify(defaultConfig[key as keyof WinboatConfigObj])
`Added missing config key: ${key} with default value: ${JSON.stringify(defaultConfig[key as keyof WinboatConfigObj])
}`,
);
}
Expand Down
23 changes: 22 additions & 1 deletion src/renderer/lib/winboat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,28 @@ export class Winboat {
}

async launchApp(app: WinApp) {
if (!this.isOnline) throw new Error("Cannot launch app, Winboat is offline");
if (!this.isOnline.value) throw new Error("Cannot launch app, Winboat is offline");

// Track recent app
const config = this.#wbConfig!.config;
const recentApps = config.recentApps;
const existingIndex = recentApps.findIndex(r => r.name === app.Name);

if (existingIndex > -1) {
recentApps.splice(existingIndex, 1);
}

recentApps.unshift({
name: app.Name,
timestamp: Date.now()
});

// Keep only last 15 apps
if (recentApps.length > 15) {
recentApps.length = 15;
}

this.#wbConfig!.config = config;

if (customAppCallbacks[app.Path]) {
logger.info(`Found custom app command for '${app.Name}'`);
Expand Down
145 changes: 136 additions & 9 deletions src/renderer/views/Apps.vue
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,15 @@
</x-label>
</x-menuitem>



<x-menuitem value="recent">
<x-label>
<span class="qualifier"> Filter: </span>
Recent
</x-label>
</x-menuitem>

<x-menuitem v-for="(label, value) in AllSources" :value="value">
<x-label>
<span class="qualifier"> Filter: </span>
Expand All @@ -182,8 +191,79 @@
</div>
</div>
<div v-if="winboat.isOnline.value" class="px-2">
<!-- Default View with Sections -->
<div v-if="isDefaultView" class="flex flex-col gap-8">

<!-- Recent Section -->
<section v-if="recentApps.length > 0">
<div class="flex items-center gap-2 mb-4 px-1">
<h2 class="text-base font-bold text-white tracking-wide">Recent</h2>
</div>
<TransitionGroup
name="apps"
tag="x-card"
class="grid gap-4 bg-transparent border-none app-grid"
>
<x-card
v-for="app of recentApps"
:key="app.id"
class="flex relative flex-row gap-2 justify-between items-center p-2 my-0 backdrop-blur-xl backdrop-brightness-150 cursor-pointer generic-hover bg-neutral-800/20"
:class="{ 'bg-gradient-to-r from-yellow-600/20 bg-neutral-800/20': app.Source === 'custom' }"
@click="winboat.launchApp(app)"
@contextmenu="openContextMenu($event, app)"
>
<div class="flex flex-row items-center gap-2 flex-1 min-w-0">
<img
class="rounded-md size-10 shrink-0"
:src="`data:image/png;charset=utf-8;base64,${app.Icon}`"
alt="App Icon"
/>
<x-label class="truncate text-ellipsis">{{ app.Name }}</x-label>
</div>
<div class="flex items-center gap-2 shrink-0">
<Icon icon="cuida:caret-right-outline" class="text-white/30"></Icon>
</div>
</x-card>
</TransitionGroup>
</section>

<!-- All Apps Section -->
<section>
<div class="flex items-center gap-2 mb-4 px-1" v-if="recentApps.length > 0">
<h2 class="text-base font-bold text-white tracking-wide">All Apps</h2>
</div>
<TransitionGroup
name="apps"
tag="x-card"
class="grid gap-4 bg-transparent border-none app-grid"
>
<x-card
v-for="app of sortedApps"
:key="app.id"
class="flex relative flex-row gap-2 justify-between items-center p-2 my-0 backdrop-blur-xl backdrop-brightness-150 cursor-pointer generic-hover bg-neutral-800/20"
:class="{ 'bg-gradient-to-r from-yellow-600/20 bg-neutral-800/20': app.Source === 'custom' }"
@click="winboat.launchApp(app)"
@contextmenu="openContextMenu($event, app)"
>
<div class="flex flex-row items-center gap-2 flex-1 min-w-0">
<img
class="rounded-md size-10 shrink-0"
:src="`data:image/png;charset=utf-8;base64,${app.Icon}`"
alt="App Icon"
/>
<x-label class="truncate text-ellipsis">{{ app.Name }}</x-label>
</div>
<div class="flex items-center gap-2 shrink-0">
<Icon icon="cuida:caret-right-outline" class="text-white/30"></Icon>
</div>
</x-card>
</TransitionGroup>
</section>
</div>

<!-- Filtered/Search View -->
<TransitionGroup
v-if="apps.length"
v-else-if="apps.length"
name="apps"
tag="x-card"
class="grid gap-4 bg-transparent border-none app-grid"
Expand All @@ -196,15 +276,17 @@
@click="winboat.launchApp(app)"
@contextmenu="openContextMenu($event, app)"
>
<div class="flex flex-row items-center gap-2 w-[85%]">
<div class="flex flex-row items-center gap-2 flex-1 min-w-0">
<img
class="rounded-md size-10"
class="rounded-md size-10 shrink-0"
:src="`data:image/png;charset=utf-8;base64,${app.Icon}`"
alt="App Icon"
/>
<x-label class="truncate text-ellipsis">{{ app.Name }}</x-label>
</div>
<Icon icon="cuida:caret-right-outline"></Icon>
<div class="flex items-center gap-2 shrink-0">
<Icon icon="cuida:caret-right-outline" class="text-white/30"></Icon>
</div>
</x-card>
</TransitionGroup>
<div v-else class="flex justify-center items-center mt-40">
Expand All @@ -221,6 +303,8 @@
<x-label>Edit</x-label>
</WBMenuItem>



<WBMenuItem v-if="contextMenuTarget?.Source === 'custom'" @click="removeCustomApp">
<Icon class="size-4" icon="mdi:trash-can-outline"></Icon>
<x-label>Remove</x-label>
Expand Down Expand Up @@ -300,13 +384,46 @@ const AllSources = computed(() => {
return sourceList;
});

const isDefaultView = computed(() => {
return filterBy.value === "all" && !searchInput.value;
});

const recentApps = computed(() => {
return apps.value
.filter(app => app.lastLaunched)
.sort((a, b) => (b.lastLaunched ?? 0) - (a.lastLaunched ?? 0))
.slice(0, 10);
});

const sortedApps = computed(() => {
let list = [...apps.value];

if (sortBy.value === "usage") {
list.sort((a, b) => (b.Usage ?? 0) - (a.Usage ?? 0));
} else {
list.sort((a, b) => a.Name.localeCompare(b.Name));
}

return list;
});

const computedApps = computed(() => {
// Make copy, otherwise UI might glitch, creating "ghost" app
let appsCache = [...apps.value];

if (filterBy.value !== "all") {
if (filterBy.value === "recent") {
appsCache = appsCache.filter(app => app.lastLaunched);
// Sort by most recent first for recent filter
appsCache.sort((a, b) => (b.lastLaunched ?? 0) - (a.lastLaunched ?? 0));
// Limit to 15 most recent
appsCache = appsCache.slice(0, 15);
return appsCache; // Return early to skip other sorting
} else if (filterBy.value !== "all") {
appsCache = appsCache.filter(app => app.Source === filterBy.value);
}

// ... rest of computedApps logic


if (searchInput.value) {
appsCache = appsCache.filter(app => app.Name.toLowerCase().includes(searchInput.value.toLowerCase()));
Expand Down Expand Up @@ -345,11 +462,19 @@ onMounted(async () => {

async function refreshApps() {
if (winboat.isOnline.value) {
const config = WinboatConfig.getInstance().config;
const loadedApps = await winboat.appMgr!.getApps(winboat.apiUrl!);
apps.value = loadedApps.map(app => ({
...app,
id: crypto.randomUUID(),
}));
apps.value = loadedApps.map(app => {
// Find last launched timestamp
const recentApp = config.recentApps.find(r => r.name === app.Name);
const lastLaunched = recentApp?.timestamp;

return {
...app,
id: crypto.randomUUID(),
lastLaunched,
};
});
// Run in background, won't impact UX
await winboat.appMgr!.updateAppCache(winboat.apiUrl!);
}
Expand Down Expand Up @@ -522,6 +647,8 @@ async function removeCustomApp() {
await refreshApps();
}



async function resetCustomAppForm() {
// So there is no visual flicker while the dialog is closing
setTimeout(() => {
Expand Down
Loading