unify notepad config with the app config

This commit is contained in:
Egor 2025-09-23 21:13:41 -07:00
parent 7ca81f05ec
commit 18c94fd7dd
10 changed files with 197 additions and 179 deletions

View file

@ -1,7 +1,7 @@
{
"name": "gerbil",
"productName": "Gerbil",
"version": "1.5.0",
"version": "1.5.1",
"description": "Run Large Language Models locally",
"main": "out/main/index.js",
"homepage": "./",

View file

@ -26,45 +26,40 @@ export const NotepadContainer = () => {
const [resizeDirection, setResizeDirection] = useState<string | null>(null);
const [confirmCloseModal, setConfirmCloseModal] = useState<{
isOpen: boolean;
tabId: string | null;
tabTitle: string;
title: string;
}>({
isOpen: false,
tabId: null,
tabTitle: '',
title: '',
});
const activeTab = tabs.find((tab) => tab.id === activeTabId);
const activeTab = tabs.find((tab) => tab.title === activeTabId);
const handleCreateNewTab = async () => {
const newTab = await window.electronAPI.notepad.createNewTab();
addTab(newTab);
};
const handleTabCloseRequest = (tabId: string) => {
const tab = tabs.find((t) => t.id === tabId);
const handleTabCloseRequest = (title: string) => {
const tab = tabs.find((t) => t.title === title);
if (!tab) return;
if (tab.content.trim().length > 0) {
setConfirmCloseModal({
isOpen: true,
tabId,
tabTitle: tab.title,
title,
});
} else {
removeTab(tabId);
removeTab(title);
}
};
const handleConfirmClose = () => {
if (confirmCloseModal.tabId) {
removeTab(confirmCloseModal.tabId);
}
setConfirmCloseModal({ isOpen: false, tabId: null, tabTitle: '' });
removeTab(confirmCloseModal.title);
setConfirmCloseModal({ isOpen: false, title: '' });
};
const handleCancelClose = () => {
setConfirmCloseModal({ isOpen: false, tabId: null, tabTitle: '' });
setConfirmCloseModal({ isOpen: false, title: '' });
};
const handleResizeStart =
@ -207,7 +202,7 @@ export const NotepadContainer = () => {
onClick={() => setVisible(false)}
color="red"
>
<X size={12} />
<X size={16} />
</ActionIcon>
</Box>
</Box>
@ -219,7 +214,7 @@ export const NotepadContainer = () => {
<CloseConfirmModal
isOpen={confirmCloseModal.isOpen}
tabTitle={confirmCloseModal.tabTitle}
tabTitle={confirmCloseModal.title}
onConfirm={handleConfirmClose}
onCancel={handleCancelClose}
/>

View file

@ -38,12 +38,12 @@ export const NotepadEditor = ({ tab }: NotepadEditorProps) => {
}
const timeout = setTimeout(() => {
saveTabContent(tab.id, newContent);
saveTabContent(tab.title, newContent);
}, 500);
setSaveTimeout(timeout);
},
[tab.id, saveTabContent, saveTimeout]
[tab.title, saveTabContent, saveTimeout]
);
const handleEditorContextMenu = (e: MouseEvent) => {
@ -62,7 +62,7 @@ export const NotepadEditor = ({ tab }: NotepadEditorProps) => {
useEffect(() => {
setContent(tab.content);
}, [tab.content, tab.id]);
}, [tab.content, tab.title]);
useEffect(
() => () => {

View file

@ -13,14 +13,13 @@ import { usePreferencesStore } from '@/stores/preferences';
interface NotepadTabsProps {
onCreateNewTab: () => Promise<void>;
onCloseTab: (tabId: string) => void;
onCloseTab: (title: string) => void;
}
interface TabProps {
id: string;
title: string;
index: number;
isActive: boolean;
title: string;
onSelect: () => void;
onClose: (e: MouseEvent) => void;
onDragStart: (e: DragEvent, index: number) => void;
@ -166,6 +165,7 @@ const Tab = ({
) : (
<Text
size="xs"
title={title}
onClick={handleTitleClick}
onDoubleClick={handleTitleDoubleClick}
onContextMenu={(e) => {
@ -208,17 +208,18 @@ export const NotepadTabs = ({
const [draggedTabIndex, setDraggedTabIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const handleTabSelect = (tabId: string) => {
setActiveTab(tabId);
const handleTabSelect = (title: string) => {
setActiveTab(title);
};
const handleTabClose = (e: MouseEvent, tabId: string) => {
const handleTabClose = (e: MouseEvent, title: string) => {
e.stopPropagation();
onCloseTab(tabId);
onCloseTab(title);
};
const handleTabRename = (tabId: string, newTitle: string) => {
updateTab(tabId, { title: newTitle });
const handleTabRename = (title: string, newTitle: string) => {
updateTab(title, { title: newTitle });
window.electronAPI.notepad.renameTab(title, newTitle);
};
const handleTabBarContextMenu = (e: MouseEvent) => {
@ -258,8 +259,6 @@ export const NotepadTabs = ({
setDragOverIndex(null);
};
if (tabs.length === 0) return null;
return (
<Box
onContextMenu={handleTabBarContextMenu}
@ -276,18 +275,17 @@ export const NotepadTabs = ({
>
{tabs.map((tab, index) => (
<Box
key={tab.id}
key={tab.title}
onDragEnter={() => handleDragEnter(index)}
onDragLeave={handleDragLeave}
>
<Tab
id={tab.id}
index={index}
isActive={tab.id === activeTabId}
title={tab.title}
onSelect={() => handleTabSelect(tab.id)}
onClose={(e) => handleTabClose(e, tab.id)}
onRename={(newTitle) => handleTabRename(tab.id, newTitle)}
index={index}
isActive={tab.title === activeTabId}
onSelect={() => handleTabSelect(tab.title)}
onClose={(e) => handleTabClose(e, tab.title)}
onRename={(newTitle) => handleTabRename(tab.title, newTitle)}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
@ -303,7 +301,7 @@ export const NotepadTabs = ({
size="xs"
onClick={onCreateNewTab}
style={{
margin: '4px',
margin: '0.25rem',
alignSelf: 'center',
}}
>

View file

@ -50,6 +50,7 @@ import {
loadNotepadState,
deleteTabFile,
createNewTab,
renameTab,
} from '@/main/modules/notepad';
import {
detectGPU,
@ -279,11 +280,15 @@ export function setupIPCHandlers() {
ipcMain.handle(
'notepad:saveTabContent',
(_, tabId: string, content: string) => saveTabContent(tabId, content)
(_, title: string, content: string) => saveTabContent(title, content)
);
ipcMain.handle('notepad:loadTabContent', (_, tabId: string) =>
loadTabContent(tabId)
ipcMain.handle('notepad:loadTabContent', (_, title: string) =>
loadTabContent(title)
);
ipcMain.handle('notepad:renameTab', (_, oldTitle: string, newTitle: string) =>
renameTab(oldTitle, newTitle)
);
ipcMain.handle('notepad:saveState', (_, state: NotepadState) =>
@ -292,8 +297,8 @@ export function setupIPCHandlers() {
ipcMain.handle('notepad:loadState', () => loadNotepadState());
ipcMain.handle('notepad:deleteTab', (_, tabId: string) =>
deleteTabFile(tabId)
ipcMain.handle('notepad:deleteTab', (_, title: string) =>
deleteTabFile(title)
);
ipcMain.handle('notepad:createNewTab', (_, title?: string) =>

View file

@ -8,6 +8,7 @@ import { nativeTheme } from 'electron';
import { PRODUCT_NAME } from '@/constants';
import type { FrontendPreference } from '@/types';
import type { MantineColorScheme } from '@mantine/core';
import type { SavedNotepadState } from '@/types/electron';
interface WindowBounds {
x: number;
@ -28,6 +29,7 @@ interface AppConfig {
skipEjectConfirmation?: boolean;
dismissedUpdates?: string[];
zoomLevel?: number;
notepad?: SavedNotepadState;
}
let config: AppConfig = {};

View file

@ -1,116 +1,119 @@
import { promises as fs } from 'fs';
import { join } from 'path';
import { safeExecute, tryExecute } from '@/utils/node/logging';
import { getInstallDir } from './config';
import type { SavedNotepadState, SavedNotepadTab } from '@/types/electron';
import { get, set, getInstallDir } from './config';
import type { SavedNotepadState } from '@/types/electron';
import {
DEFAULT_NOTEPAD_POSITION,
DEFAULT_TAB_CONTENT,
} from '@/constants/notepad';
const NOTEPAD_DIR = join(getInstallDir(), 'notepad');
const NOTEPAD_STATE_FILE = join(NOTEPAD_DIR, 'state.json');
import { pathExists } from '@/utils/node/fs';
import { join } from 'path';
import { readFile, readdir, writeFile, unlink, rename } from 'fs/promises';
const DEFAULT_NOTEPAD_STATE: SavedNotepadState = {
tabs: [],
activeTabId: null,
position: DEFAULT_NOTEPAD_POSITION,
isVisible: false,
};
async function ensureNotepadDir() {
return tryExecute(async () => {
await fs.mkdir(NOTEPAD_DIR, { recursive: true });
}, 'Failed to create notepad directory');
}
const getNotepadDir = () => join(getInstallDir(), 'notepad');
export async function saveTabContent(tabId: string, content: string) {
return tryExecute(async () => {
await ensureNotepadDir();
const filePath = join(NOTEPAD_DIR, `${tabId}.txt`);
await fs.writeFile(filePath, content, 'utf8');
}, 'Failed to save tab content');
}
const getTabsFromStorage = async () => {
const tabs = [];
export async function loadTabContent(tabId: string) {
const filePath = join(NOTEPAD_DIR, `${tabId}.txt`);
try {
return fs.readFile(filePath, 'utf8');
} catch (error) {
if ((error as { code?: string }).code === 'ENOENT') {
return '';
}
throw error;
}
}
const notepadDir = getNotepadDir();
if (await pathExists(notepadDir)) {
const files = await readdir(notepadDir);
const tabFiles = files.filter((f) => f.endsWith('.txt'));
export async function saveNotepadState(state: SavedNotepadState) {
return tryExecute(async () => {
await ensureNotepadDir();
await fs.writeFile(
NOTEPAD_STATE_FILE,
JSON.stringify(state, null, 2),
'utf8'
);
}, 'Failed to save notepad state');
}
export async function loadNotepadState() {
const result = await safeExecute(async () => {
try {
const data = await fs.readFile(NOTEPAD_STATE_FILE, 'utf8');
return JSON.parse(data) as SavedNotepadState;
} catch {
return DEFAULT_NOTEPAD_STATE;
}
}, 'Failed to load notepad state');
return result || DEFAULT_NOTEPAD_STATE;
}
export async function deleteTabFile(tabId: string) {
return tryExecute(async () => {
const filePath = join(NOTEPAD_DIR, `${tabId}.txt`);
try {
await fs.unlink(filePath);
} catch (error) {
if ((error as { code?: string }).code !== 'ENOENT') {
throw error;
for (const file of tabFiles) {
const title = file.replace('.txt', '');
tabs.push({ title });
}
}
}, 'Failed to delete tab file');
}
} catch {
return [];
}
let tabCounter = 1;
return tabs;
};
export async function createNewTab(title?: string) {
export const renameTab = async (oldTitle: string, newTitle: string) => {
try {
const notepadDir = getNotepadDir();
await rename(
join(notepadDir, `${oldTitle}.txt`),
join(notepadDir, `${newTitle}.txt`)
);
return true;
} catch {
return false;
}
};
export const saveTabContent = async (title: string, content: string) => {
try {
const notepadDir = getNotepadDir();
const filePath = join(notepadDir, `${title}.txt`);
await writeFile(filePath, content, 'utf-8');
return true;
} catch {
return false;
}
};
export const loadTabContent = async (title: string) => {
try {
const notepadDir = getNotepadDir();
return readFile(join(notepadDir, `${title}.txt`), 'utf-8');
} catch {
return '';
}
};
export const saveNotepadState = async (state: SavedNotepadState) => {
await set('notepad', state);
return true;
};
export const loadNotepadState = async () => {
const stored = get('notepad') || DEFAULT_NOTEPAD_STATE;
const tabs = await getTabsFromStorage();
const activeTabId =
stored.activeTabId && tabs.some((tab) => tab.title === stored.activeTabId)
? stored.activeTabId
: tabs[0]?.title || null;
return { ...stored, tabs, activeTabId };
};
export const deleteTabFile = async (title: string) => {
try {
await unlink(join(getNotepadDir(), `${title}.txt`));
return true;
} catch {
return false;
}
};
export const createNewTab = async (title?: string) => {
if (!title) {
const state = await loadNotepadState();
const noteNumbers = state.tabs
.map((tab: SavedNotepadTab) => {
const match = tab.title.match(/^Note (\d+)$/);
return match ? parseInt(match[1], 10) : 0;
})
.filter((num: number) => num > 0)
.sort((a: number, b: number) => a - b);
.map((tab) => tab.title.match(/^Note (\d+)$/)?.[1])
.filter(Boolean)
.map(Number)
.sort((a, b) => a - b);
tabCounter = 1;
let counter = 1;
for (const num of noteNumbers) {
if (num === tabCounter) {
tabCounter++;
} else {
break;
}
if (num === counter) counter++;
else break;
}
title = `Note ${counter}`;
}
const newTab = {
id: `tab-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
title: title || `Note ${tabCounter++}`,
content: DEFAULT_TAB_CONTENT,
};
await saveTabContent(newTab.id, newTab.content);
return newTab;
}
await saveTabContent(title, DEFAULT_TAB_CONTENT);
return { title, content: DEFAULT_TAB_CONTENT };
};

View file

@ -191,13 +191,15 @@ const updaterAPI: UpdaterAPI = {
};
const notepadAPI: NotepadAPI = {
saveTabContent: (tabId, content) =>
ipcRenderer.invoke('notepad:saveTabContent', tabId, content),
loadTabContent: (tabId) =>
ipcRenderer.invoke('notepad:loadTabContent', tabId),
saveTabContent: (title, content) =>
ipcRenderer.invoke('notepad:saveTabContent', title, content),
loadTabContent: (title) =>
ipcRenderer.invoke('notepad:loadTabContent', title),
renameTab: (oldTitle, newTitle) =>
ipcRenderer.invoke('notepad:renameTab', oldTitle, newTitle),
saveState: (state) => ipcRenderer.invoke('notepad:saveState', state),
loadState: () => ipcRenderer.invoke('notepad:loadState'),
deleteTab: (tabId) => ipcRenderer.invoke('notepad:deleteTab', tabId),
deleteTab: (title) => ipcRenderer.invoke('notepad:deleteTab', title),
createNewTab: (title) => ipcRenderer.invoke('notepad:createNewTab', title),
};

View file

@ -9,17 +9,17 @@ import {
interface NotepadStore extends NotepadState {
isLoaded: boolean;
setTabs: (tabs: NotepadTab[]) => void;
setActiveTab: (tabId: string | null) => void;
setActiveTab: (title: string | null) => void;
addTab: (tab: NotepadTab) => void;
updateTab: (tabId: string, updates: Partial<NotepadTab>) => void;
removeTab: (tabId: string) => void;
updateTab: (title: string, updates: Partial<NotepadTab>) => void;
removeTab: (title: string) => void;
reorderTabs: (fromIndex: number, toIndex: number) => void;
setPosition: (position: NotepadState['position']) => void;
setVisible: (visible: boolean) => void;
setShowLineNumbers: (showLineNumbers: boolean) => void;
loadState: () => Promise<void>;
saveState: () => Promise<void>;
saveTabContent: (tabId: string, content: string) => Promise<void>;
saveTabContent: (title: string, content: string) => Promise<void>;
}
export const useNotepadStore = create<NotepadStore>()(
@ -33,45 +33,55 @@ export const useNotepadStore = create<NotepadStore>()(
setTabs: (tabs) => set({ tabs }),
setActiveTab: (tabId) => set({ activeTabId: tabId }),
setActiveTab: (title) => set({ activeTabId: title }),
addTab: (tab) => {
set((state) => ({
tabs: [...state.tabs, tab],
activeTabId: tab.id,
activeTabId: tab.title,
}));
},
updateTab: (tabId, updates) => {
set((state) => ({
tabs: state.tabs.map((tab) =>
tab.id === tabId ? { ...tab, ...updates } : tab
),
}));
updateTab: (title, updates) => {
set((state) => {
const updatedTabs = state.tabs.map((tab) =>
tab.title === title ? { ...tab, ...updates } : tab
);
let newActiveTabId = state.activeTabId;
if (updates.title && state.activeTabId === title) {
newActiveTabId = updates.title;
}
return {
tabs: updatedTabs,
activeTabId: newActiveTabId,
};
});
},
removeTab: (tabId) => {
removeTab: (title) => {
const state = get();
if (state.tabs.length <= 1) {
const tab = state.tabs.find((t) => t.id === tabId);
const tab = state.tabs.find((t) => t.title === title);
if (tab) {
set((state) => ({
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, content: DEFAULT_TAB_CONTENT } : t
t.title === title ? { ...t, content: DEFAULT_TAB_CONTENT } : t
),
}));
window.electronAPI.notepad.saveTabContent(tabId, DEFAULT_TAB_CONTENT);
window.electronAPI.notepad.saveTabContent(title, DEFAULT_TAB_CONTENT);
}
return;
}
const newTabs = state.tabs.filter((tab) => tab.id !== tabId);
const newTabs = state.tabs.filter((tab) => tab.title !== title);
const newActiveTabId =
state.activeTabId === tabId
state.activeTabId === title
? newTabs.length > 0
? newTabs[Math.max(0, newTabs.length - 1)].id
? newTabs[Math.max(0, newTabs.length - 1)].title
: null
: state.activeTabId;
@ -80,7 +90,7 @@ export const useNotepadStore = create<NotepadStore>()(
activeTabId: newActiveTabId,
});
window.electronAPI.notepad.deleteTab(tabId);
window.electronAPI.notepad.deleteTab(title);
},
reorderTabs: (fromIndex, toIndex) => {
@ -109,10 +119,14 @@ export const useNotepadStore = create<NotepadStore>()(
const savedState = await window.electronAPI.notepad.loadState();
const tabsWithContent = await Promise.all(
savedState.tabs.map(async (tab) => ({
...tab,
content: await window.electronAPI.notepad.loadTabContent(tab.id),
}))
savedState.tabs.map((tab) =>
window.electronAPI.notepad
.loadTabContent(tab.title)
.then((content) => ({
...tab,
content,
}))
)
);
if (tabsWithContent.length === 0) {
@ -122,7 +136,8 @@ export const useNotepadStore = create<NotepadStore>()(
set({
tabs: tabsWithContent,
activeTabId: savedState.activeTabId || tabsWithContent[0]?.id || null,
activeTabId:
savedState.activeTabId || tabsWithContent[0]?.title || null,
position: savedState.position,
isVisible: savedState.isVisible,
showLineNumbers: savedState.showLineNumbers ?? true,
@ -132,7 +147,7 @@ export const useNotepadStore = create<NotepadStore>()(
const defaultTab = await window.electronAPI.notepad.createNewTab();
set({
tabs: [defaultTab],
activeTabId: defaultTab.id,
activeTabId: defaultTab.title,
position: DEFAULT_NOTEPAD_POSITION,
isVisible: false,
isLoaded: true,
@ -145,10 +160,6 @@ export const useNotepadStore = create<NotepadStore>()(
if (!state.isLoaded) return;
await window.electronAPI.notepad.saveState({
tabs: state.tabs.map((tab) => ({
id: tab.id,
title: tab.title,
})),
activeTabId: state.activeTabId,
position: state.position,
isVisible: state.isVisible,
@ -156,9 +167,9 @@ export const useNotepadStore = create<NotepadStore>()(
});
},
saveTabContent: async (tabId, content) => {
await window.electronAPI.notepad.saveTabContent(tabId, content);
get().updateTab(tabId, { content });
saveTabContent: async (title, content) => {
await window.electronAPI.notepad.saveTabContent(title, content);
get().updateTab(title, { content });
},
}))
);

View file

@ -210,13 +210,11 @@ export interface UpdaterAPI {
}
export interface NotepadTab {
id: string;
title: string;
content: string;
}
export interface SavedNotepadTab {
id: string;
title: string;
}
@ -232,7 +230,6 @@ export interface NotepadState {
}
export interface SavedNotepadState {
tabs: SavedNotepadTab[];
activeTabId: string | null;
position: {
width: number;
@ -242,12 +239,17 @@ export interface SavedNotepadState {
showLineNumbers?: boolean;
}
export interface NotepadStateWithTabs extends SavedNotepadState {
tabs: SavedNotepadTab[];
}
export interface NotepadAPI {
saveTabContent: (tabId: string, content: string) => Promise<boolean>;
loadTabContent: (tabId: string) => Promise<string>;
saveTabContent: (title: string, content: string) => Promise<boolean>;
loadTabContent: (title: string) => Promise<string>;
renameTab: (oldTitle: string, newTitle: string) => Promise<boolean>;
saveState: (state: SavedNotepadState) => Promise<boolean>;
loadState: () => Promise<SavedNotepadState>;
deleteTab: (tabId: string) => Promise<boolean>;
loadState: () => Promise<NotepadStateWithTabs>;
deleteTab: (title: string) => Promise<boolean>;
createNewTab: (title?: string) => Promise<NotepadTab>;
}