better old window state recreation, more distrinct select option highlights in light mode, better updated version cleanup

This commit is contained in:
Egor 2025-09-24 23:14:46 -07:00
parent 03b62d3499
commit 7cef3ce90d
20 changed files with 133 additions and 181 deletions

View file

@ -1,7 +1,7 @@
{
"name": "gerbil",
"productName": "Gerbil",
"version": "1.5.2",
"version": "1.5.3",
"description": "Run Large Language Models locally",
"main": "out/main/index.js",
"homepage": "./",
@ -15,7 +15,7 @@
"build": "electron-vite build",
"package": "electron-vite build && electron-builder --publish=never",
"analyze": "cross-env ANALYZE=true electron-vite build && yarn dlx open-cli dist/stats.html",
"format": "prettier --write . --ignore-path .gitignore",
"format": "prettier --write . --ignore-path .gitignore | grep -v '(unchanged)' || true",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"compile": "tsc --noEmit",
@ -68,8 +68,8 @@
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.38.3",
"@fontsource/inter": "^5.2.8",
"@mantine/core": "^8.3.1",
"@mantine/hooks": "^8.3.1",
"@mantine/core": "8.3.1",
"@mantine/hooks": "8.3.1",
"@types/yauzl": "^2.10.3",
"@uiw/react-codemirror": "^4.25.2",
"electron-updater": "6.6.2",

View file

@ -3,7 +3,6 @@ import {
ActionIcon,
Box,
Image,
Select,
AppShell,
Tooltip,
} from '@mantine/core';
@ -18,6 +17,7 @@ import { UpdateButton } from '@/components/App/UpdateButton';
import icon from '/icon.png';
import { PRODUCT_NAME, TITLEBAR_HEIGHT } from '@/constants';
import type { InterfaceTab, Screen, SelectOption } from '@/types';
import { Select } from '@/components/Select';
interface TitleBarProps {
currentScreen: Screen;
@ -135,9 +135,7 @@ export const TitleBar = ({
onDropdownClose={() => setIsSelectOpen(false)}
data={interfaceOptions}
renderOption={renderOption}
allowDeselect={false}
variant="unstyled"
size="sm"
style={{ textAlign: 'center', minWidth: '7.5rem' }}
styles={{
input: {

View file

@ -91,9 +91,9 @@ export const UpdateAvailableModal = ({
</div>
</Group>
{currentVersion && (
{availableUpdate?.name && (
<Text size="xs" c="dimmed">
Binary Type: {pretifyBinName(currentVersion.filename)}
Binary Type: {pretifyBinName(availableUpdate?.name)}
</Text>
)}

View file

@ -90,10 +90,13 @@ export const App = () => {
};
const handleBinaryUpdate = async (download: DownloadItem) => {
const currentVersion = await window.electronAPI.kobold.getCurrentVersion();
await handleDownload({
item: download,
isUpdate: true,
wasCurrentBinary: true,
oldVersionPath: currentVersion?.path,
});
closeModal();

16
src/components/Select.tsx Normal file
View file

@ -0,0 +1,16 @@
import { Select as MantineSelect } from '@mantine/core';
import type { SelectProps } from '@mantine/core';
export const Select = ({
allowDeselect = false,
clearable = false,
size = 'sm',
...props
}: SelectProps) => (
<MantineSelect
allowDeselect={allowDeselect}
clearable={clearable}
size={size}
{...props}
/>
);

View file

@ -1,8 +1,8 @@
import type { CSSProperties } from 'react';
import { Select } from '@mantine/core';
import type { ComboboxItem } from '@mantine/core';
import { LabelWithTooltip } from '@/components/LabelWithTooltip';
import type { SelectOption } from '@/types';
import { Select } from '@/components/Select';
interface SelectWithTooltipProps {
label: string;
@ -36,7 +36,6 @@ export const SelectWithTooltip = ({
data={data}
disabled={disabled}
clearable={clearable}
allowDeselect={false}
/>
</div>
);

View file

@ -1,9 +1,10 @@
import { Stack, Text, Group, Button, Select, Tooltip } from '@mantine/core';
import { Stack, Text, Group, Button, Tooltip } from '@mantine/core';
import { useState, useCallback } from 'react';
import { Save, File, Plus, Check, Trash2 } from 'lucide-react';
import type { ConfigFile } from '@/types';
import { CreateConfigModal } from './CreateConfigModal';
import { DeleteConfigModal } from './DeleteConfigModal';
import { Select } from '@/components/Select';
interface ConfigFileManagerProps {
configFiles: ConfigFile[];
@ -108,8 +109,6 @@ export const ConfigFileManager = ({
data={selectData}
leftSection={<File size={16} />}
searchable
clearable={false}
allowDeselect={false}
/>
</div>
<Button

View file

@ -1,10 +1,11 @@
import { Text, Group, Select, Checkbox, TextInput } from '@mantine/core';
import { Text, Group, Checkbox, TextInput } from '@mantine/core';
import { useState, useEffect, useRef } from 'react';
import { InfoTooltip } from '@/components/InfoTooltip';
import { BackendSelectItem } from '@/components/screens/Launch/GeneralTab/BackendSelectItem';
import { GpuDeviceSelector } from '@/components/screens/Launch/GeneralTab/GpuDeviceSelector';
import { useLaunchConfig } from '@/hooks/useLaunchConfig';
import type { BackendOption } from '@/types';
import { Select } from '@/components/Select';
export const BackendSelector = () => {
const {
@ -89,11 +90,11 @@ export const BackendSelector = () => {
disabled: b.disabled,
}))}
disabled={isLoadingBackends || availableBackends.length === 0}
allowDeselect={false}
renderOption={({ option }) => {
const backendData = availableBackends.find(
(b) => b.value === option.value
);
return (
<BackendSelectItem
label={backendData?.label || option.label.split(' (')[0]}

View file

@ -1,6 +1,7 @@
import { Text, Group, Select, TextInput } from '@mantine/core';
import { Text, Group, TextInput } from '@mantine/core';
import { InfoTooltip } from '@/components/InfoTooltip';
import { useLaunchConfig } from '@/hooks/useLaunchConfig';
import { Select } from '@/components/Select';
import type { BackendOption } from '@/types';
interface GpuDeviceSelectorProps {
@ -68,7 +69,6 @@ export const GpuDeviceSelector = ({
}
}}
data={deviceOptions}
allowDeselect={false}
/>
</div>

View file

@ -6,7 +6,6 @@ import {
TextInput,
Button,
rem,
Select,
Box,
Anchor,
} from '@mantine/core';
@ -14,6 +13,7 @@ import { Folder, FolderOpen, Monitor, ExternalLink } from 'lucide-react';
import type { FrontendPreference } from '@/types';
import { usePreferencesStore } from '@/stores/preferences';
import { FRONTENDS } from '@/constants';
import { Select } from '@/components/Select';
interface FrontendRequirement {
id: string;
@ -267,7 +267,6 @@ export const GeneralTab = ({
disabled: !isFrontendAvailable(config.value),
}))}
leftSection={<Monitor style={{ width: rem(16), height: rem(16) }} />}
allowDeselect={false}
/>
<Box mt="sm">

View file

@ -188,6 +188,7 @@ export const VersionsTab = () => {
item: download,
isUpdate: true,
wasCurrentBinary: version.isCurrent,
oldVersionPath: version.installedPath,
});
await loadInstalledVersions();

View file

@ -2,7 +2,6 @@ import { ipcMain, app } from 'electron';
import { join } from 'path';
import { release } from 'os';
import { platform, versions, arch } from 'process';
import type { MantineColorScheme } from '@mantine/core';
import {
stopKoboldCpp,
launchKoboldCppWithCustomFrontends,
@ -76,13 +75,12 @@ import {
canAutoUpdate,
} from '@/main/modules/autoUpdater';
import { getAppVersion } from '@/utils/node/fs';
import type { NotepadState } from '@/types/electron';
export function setupIPCHandlers() {
const mainWindow = getMainWindow();
ipcMain.handle('kobold:downloadRelease', async (_, asset) =>
downloadRelease(asset)
ipcMain.handle('kobold:downloadRelease', async (_, asset, options) =>
downloadRelease(asset, options)
);
ipcMain.handle('kobold:getInstalledVersions', () => getInstalledVersions());
@ -182,7 +180,7 @@ export function setupIPCHandlers() {
};
});
ipcMain.handle('app:openPath', (_, path: string) => openPathHandler(path));
ipcMain.handle('app:openPath', (_, path) => openPathHandler(path));
ipcMain.handle('app:showLogsFolder', () =>
openPathHandler(join(app.getPath('userData'), 'logs'))
@ -208,18 +206,18 @@ export function setupIPCHandlers() {
mainWindow.webContents.getZoomLevel()
);
ipcMain.handle('app:setZoomLevel', (_, level: number) => {
ipcMain.handle('app:setZoomLevel', (_, level) => {
mainWindow.webContents.setZoomLevel(level);
setConfig('zoomLevel', level);
});
ipcMain.handle('app:getColorScheme', () => getColorScheme());
ipcMain.handle('app:setColorScheme', (_, colorScheme: MantineColorScheme) =>
ipcMain.handle('app:setColorScheme', (_, colorScheme) =>
setColorScheme(colorScheme)
);
ipcMain.handle('app:openExternal', async (_, url: string) => openUrl(url));
ipcMain.handle('app:openExternal', async (_, url) => openUrl(url));
mainWindow.webContents.once('did-finish-load', async () => {
const savedZoomLevel = await getConfig('zoomLevel');
@ -230,9 +228,7 @@ export function setupIPCHandlers() {
ipcMain.handle('app:openPerformanceManager', () => openPerformanceManager());
ipcMain.on('logs:logError', (_, message: string, error?: Error) =>
logError(message, error)
);
ipcMain.on('logs:logError', (_, message, error?) => logError(message, error));
ipcMain.handle('dependencies:isNpxAvailable', () => isNpxAvailable());
@ -257,30 +253,21 @@ export function setupIPCHandlers() {
return aurPackageVersion !== null;
});
ipcMain.handle(
'notepad:saveTabContent',
(_, title: string, content: string) => saveTabContent(title, content)
ipcMain.handle('notepad:saveTabContent', (_, title, content) =>
saveTabContent(title, content)
);
ipcMain.handle('notepad:loadTabContent', (_, title: string) =>
loadTabContent(title)
);
ipcMain.handle('notepad:loadTabContent', (_, title) => loadTabContent(title));
ipcMain.handle('notepad:renameTab', (_, oldTitle: string, newTitle: string) =>
ipcMain.handle('notepad:renameTab', (_, oldTitle, newTitle) =>
renameTab(oldTitle, newTitle)
);
ipcMain.handle('notepad:saveState', (_, state: NotepadState) =>
saveNotepadState(state)
);
ipcMain.handle('notepad:saveState', (_, state) => saveNotepadState(state));
ipcMain.handle('notepad:loadState', () => loadNotepadState());
ipcMain.handle('notepad:deleteTab', (_, title: string) =>
deleteTabFile(title)
);
ipcMain.handle('notepad:deleteTab', (_, title) => deleteTabFile(title));
ipcMain.handle('notepad:createNewTab', (_, title?: string) =>
createNewTab(title)
);
ipcMain.handle('notepad:createNewTab', (_, title) => createNewTab(title));
}

View file

@ -10,11 +10,11 @@ import type { FrontendPreference } from '@/types';
import type { MantineColorScheme } from '@mantine/core';
import type { SavedNotepadState } from '@/types/electron';
interface WindowBounds {
x: number;
y: number;
width: number;
height: number;
export interface WindowBounds {
x?: number;
y?: number;
width?: number;
height?: number;
isMaximized: boolean;
}

View file

@ -14,7 +14,7 @@ import { getMainWindow, sendToRenderer } from '../window';
import { pathExists } from '@/utils/node/fs';
import { stripAssetExtensions } from '@/utils/version';
import { getLauncherPath } from '@/utils/node/path';
import type { GitHubAsset } from '@/types/electron';
import type { DownloadReleaseOptions, GitHubAsset } from '@/types/electron';
async function removeDirectoryWithRetry(
dirPath: string,
@ -132,6 +132,7 @@ async function setupLauncher(
async function unpackKoboldCpp(packedPath: string, unpackDir: string) {
try {
await mkdir(unpackDir, { recursive: true });
await execa(packedPath, ['--unpack', unpackDir], {
timeout: 60000,
stdio: ['ignore', 'pipe', 'pipe'],
@ -148,7 +149,10 @@ async function unpackKoboldCpp(packedPath: string, unpackDir: string) {
}
}
export async function downloadRelease(asset: GitHubAsset) {
export async function downloadRelease(
asset: GitHubAsset,
options: DownloadReleaseOptions
) {
const tempPackedFilePath = join(getInstallDir(), `${asset.name}.packed`);
const baseFilename = stripAssetExtensions(asset.name);
const folderName = asset.version
@ -156,11 +160,6 @@ export async function downloadRelease(asset: GitHubAsset) {
: baseFilename;
const unpackedDirPath = join(getInstallDir(), folderName);
let currentBinaryPath: string | null = null;
if (asset.isUpdate && asset.wasCurrentBinary) {
currentBinaryPath = getCurrentKoboldBinary() || null;
}
try {
if (await pathExists(unpackedDirPath)) {
await removeDirectoryWithRetry(unpackedDirPath);
@ -168,26 +167,28 @@ export async function downloadRelease(asset: GitHubAsset) {
await downloadFile(asset, tempPackedFilePath);
await mkdir(unpackedDirPath, { recursive: true });
await unpackKoboldCpp(tempPackedFilePath, unpackedDirPath);
const launcherPath = await setupLauncher(
tempPackedFilePath,
unpackedDirPath
);
const currentBinary = getCurrentKoboldBinary();
if (!currentBinary || (asset.isUpdate && asset.wasCurrentBinary)) {
await setCurrentKoboldBinary(launcherPath);
}
if (currentBinaryPath && asset.isUpdate && asset.wasCurrentBinary) {
const oldInstallDir = join(currentBinaryPath, '..');
if (options.oldVersionPath && options.isUpdate) {
const oldInstallDir = join(options.oldVersionPath, '..');
if (oldInstallDir !== unpackedDirPath) {
await removeDirectoryWithRetry(oldInstallDir);
}
}
if (
!getCurrentKoboldBinary() ||
(options.isUpdate && options.wasCurrentBinary)
) {
await setCurrentKoboldBinary(launcherPath);
}
sendToRenderer('versions-updated');
} catch (error) {
logError('Failed to download or unpack binary:', error as Error);

View file

@ -2,10 +2,15 @@ import { BrowserWindow, app, shell, screen, Menu, clipboard } from 'electron';
import type { BrowserWindowConstructorOptions } from 'electron';
import { join } from 'path';
import { stripVTControlCharacters } from 'util';
import { PRODUCT_NAME } from '../../constants';
import { PRODUCT_NAME } from '@/constants';
import type { IPCChannel, IPCChannelPayloads } from '@/types/ipc';
import { isDevelopment } from '@/utils/node/environment';
import { getBackgroundColor, getWindowBounds, setWindowBounds } from './config';
import {
getBackgroundColor,
getWindowBounds,
setWindowBounds,
WindowBounds,
} from './config';
let mainWindow: BrowserWindow | null = null;
@ -42,35 +47,23 @@ export function createMainWindow() {
},
} as BrowserWindowConstructorOptions;
if (savedBounds) {
const isPseudoMaximized =
(savedBounds.width >= size.width * 0.95 ||
savedBounds.height >= size.height * 0.95) &&
!savedBounds.isMaximized;
if (isPseudoMaximized) {
windowOptions.width = defaultWidth;
windowOptions.height = defaultHeight;
windowOptions.x = Math.floor((size.width - defaultWidth) / 2);
windowOptions.y = Math.floor((size.height - defaultHeight) / 2);
if (savedBounds?.x !== undefined && savedBounds?.y !== undefined) {
const minVisibleSize = 100;
if (
savedBounds.x >= -minVisibleSize &&
savedBounds.y >= -minVisibleSize &&
savedBounds.x < size.width - minVisibleSize &&
savedBounds.y < size.height - minVisibleSize
) {
windowOptions.x = savedBounds.x;
windowOptions.y = savedBounds.y;
} else {
const minVisibleSize = 100;
if (
savedBounds.x >= -minVisibleSize &&
savedBounds.y >= -minVisibleSize &&
savedBounds.x < size.width - minVisibleSize &&
savedBounds.y < size.height - minVisibleSize
) {
windowOptions.x = savedBounds.x;
windowOptions.y = savedBounds.y;
} else {
windowOptions.x = Math.floor(
(size.width - (savedBounds.width || defaultWidth)) / 2
);
windowOptions.y = Math.floor(
(size.height - (savedBounds.height || defaultHeight)) / 2
);
}
windowOptions.x = Math.floor(
(size.width - (savedBounds.width || defaultWidth)) / 2
);
windowOptions.y = Math.floor(
(size.height - (savedBounds.height || defaultHeight)) / 2
);
}
} else {
windowOptions.x = Math.floor((size.width - defaultWidth) / 2);
@ -83,103 +76,38 @@ export function createMainWindow() {
mainWindow.maximize();
}
let restoreBounds: {
x: number;
y: number;
width: number;
height: number;
} | null = savedBounds
? {
x: savedBounds.x || 0,
y: savedBounds.y || 0,
width: savedBounds.width,
height: savedBounds.height,
}
: null;
const saveBounds = () => {
if (mainWindow && !mainWindow.isDestroyed()) {
const isMaximized = mainWindow.isMaximized();
const currentBounds = mainWindow.getBounds();
let bounds: WindowBounds = { isMaximized };
if (isMaximized) {
if (restoreBounds) {
setWindowBounds({
x: restoreBounds.x,
y: restoreBounds.y,
width: restoreBounds.width,
height: restoreBounds.height,
isMaximized: true,
});
}
} else {
restoreBounds = currentBounds;
setWindowBounds({
if (!isMaximized) {
bounds = {
x: currentBounds.x,
y: currentBounds.y,
width: currentBounds.width,
height: currentBounds.height,
isMaximized: false,
});
isMaximized,
};
}
setWindowBounds(bounds);
}
};
let lastSavedBounds: string | null = null;
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
const debouncedSave = () => {
if (saveTimeout) {
clearTimeout(saveTimeout);
}
saveTimeout = setTimeout(() => {
saveBounds();
saveTimeout = null;
}, 1000);
};
const checkBounds = () => {
if (mainWindow && !mainWindow.isDestroyed()) {
const currentBounds = mainWindow.getBounds();
const isMaximized = mainWindow.isMaximized();
const boundsString = JSON.stringify({ ...currentBounds, isMaximized });
if (boundsString !== lastSavedBounds) {
lastSavedBounds = boundsString;
debouncedSave();
}
}
};
const boundsInterval = setInterval(checkBounds, 500);
mainWindow.on('moved', () => {
debouncedSave();
});
mainWindow.on('resized', () => {
debouncedSave();
});
mainWindow.on('maximize', () => {
saveBounds();
sendToRenderer('window-maximized');
});
mainWindow.on('unmaximize', () => {
saveBounds();
sendToRenderer('window-unmaximized');
});
mainWindow.on('closed', () => {
if (saveTimeout) {
clearTimeout(saveTimeout);
}
clearInterval(boundsInterval);
mainWindow = null;
});
mainWindow.once('ready-to-show', () => {
mainWindow?.show();
});
mainWindow.once('ready-to-show', () => mainWindow?.show());
if (isDevelopment) {
mainWindow.loadURL('http://localhost:5173');
@ -218,6 +146,7 @@ export function createMainWindow() {
}));
mainWindow.on('close', () => {
saveBounds();
app.quit();
});

View file

@ -33,8 +33,8 @@ const koboldAPI: KoboldAPI = {
getCurrentInstallDir: () => ipcRenderer.invoke('kobold:getCurrentInstallDir'),
selectInstallDirectory: () =>
ipcRenderer.invoke('kobold:selectInstallDirectory'),
downloadRelease: (asset) =>
ipcRenderer.invoke('kobold:downloadRelease', asset),
downloadRelease: (asset, options) =>
ipcRenderer.invoke('kobold:downloadRelease', asset, options),
launchKoboldCpp: (args) => ipcRenderer.invoke('kobold:launchKoboldCpp', args),
getConfigFiles: () => ipcRenderer.invoke('kobold:getConfigFiles'),
saveConfigFile: (configName, configData) =>

View file

@ -16,6 +16,7 @@ interface HandleDownloadParams {
item: DownloadItem;
isUpdate?: boolean;
wasCurrentBinary?: boolean;
oldVersionPath?: string;
}
const transformReleaseToDownloadItems = (
@ -96,7 +97,12 @@ export const useKoboldVersionsStore = create<KoboldVersionsState>(
},
handleDownload: async (params: HandleDownloadParams) => {
const { item, isUpdate = false, wasCurrentBinary = false } = params;
const {
item,
isUpdate = false,
wasCurrentBinary = false,
oldVersionPath,
} = params;
const { downloading } = get();
if (downloading) {
@ -116,11 +122,13 @@ export const useKoboldVersionsStore = create<KoboldVersionsState>(
browser_download_url: item.url,
size: item.size,
version: item.version,
isUpdate,
wasCurrentBinary,
};
await window.electronAPI.kobold.downloadRelease(asset);
await window.electronAPI.kobold.downloadRelease(asset, {
isUpdate,
wasCurrentBinary,
oldVersionPath,
});
progressCleanup();
set({ downloading: null, downloadProgress: {} });

View file

@ -16,6 +16,10 @@
border-right: 1px solid rgba(255, 255, 255, 0.15);
}
[data-mantine-color-scheme='light'] .mantine-Select-option:hover {
background-color: #e9ecef !important;
}
/* Custom scrollbars */
::-webkit-scrollbar {
width: 0.5rem;

View file

@ -17,8 +17,12 @@ export interface GitHubAsset {
browser_download_url: string;
size: number;
version?: string;
}
export interface DownloadReleaseOptions {
isUpdate?: boolean;
wasCurrentBinary?: boolean;
oldVersionPath?: string;
}
export interface GitHubRelease {
@ -113,7 +117,10 @@ export interface KoboldAPI {
getAvailableBackends: (includeDisabled?: boolean) => Promise<BackendOption[]>;
getCurrentInstallDir: () => Promise<string>;
selectInstallDirectory: () => Promise<string | null>;
downloadRelease: (asset: GitHubAsset) => Promise<void>;
downloadRelease: (
asset: GitHubAsset,
options: DownloadReleaseOptions
) => Promise<void>;
launchKoboldCpp: (
args?: string[]
) => Promise<{ success: boolean; pid?: number; error?: string }>;

View file

@ -989,7 +989,7 @@ __metadata:
languageName: node
linkType: hard
"@mantine/core@npm:^8.3.1":
"@mantine/core@npm:8.3.1":
version: 8.3.1
resolution: "@mantine/core@npm:8.3.1"
dependencies:
@ -1007,7 +1007,7 @@ __metadata:
languageName: node
linkType: hard
"@mantine/hooks@npm:^8.3.1":
"@mantine/hooks@npm:8.3.1":
version: 8.3.1
resolution: "@mantine/hooks@npm:8.3.1"
peerDependencies:
@ -3863,8 +3863,8 @@ __metadata:
"@codemirror/view": "npm:^6.38.3"
"@eslint/js": "npm:^9.36.0"
"@fontsource/inter": "npm:^5.2.8"
"@mantine/core": "npm:^8.3.1"
"@mantine/hooks": "npm:^8.3.1"
"@mantine/core": "npm:8.3.1"
"@mantine/hooks": "npm:8.3.1"
"@types/node": "npm:^24.5.2"
"@types/react": "npm:^19.1.13"
"@types/react-dom": "npm:^19.1.9"