mirror of
https://github.com/lone-cloud/gerbil
synced 2026-06-03 19:54:44 -07:00
clean up previously installed kcpp versions after an update, bring back a needed monkey patch, small refactors
This commit is contained in:
parent
4fd87a619a
commit
03b62d3499
11 changed files with 101 additions and 126 deletions
|
|
@ -13,7 +13,8 @@ import { useState } from 'react';
|
||||||
import type { DownloadItem } from '@/types/electron';
|
import type { DownloadItem } from '@/types/electron';
|
||||||
import type { BinaryUpdateInfo } from '@/hooks/useUpdateChecker';
|
import type { BinaryUpdateInfo } from '@/hooks/useUpdateChecker';
|
||||||
import { useKoboldVersionsStore } from '@/stores/koboldVersions';
|
import { useKoboldVersionsStore } from '@/stores/koboldVersions';
|
||||||
import { getDisplayNameFromPath } from '@/utils/version';
|
import { pretifyBinName } from '@/utils/assets';
|
||||||
|
import { formatDownloadSize } from '@/utils/format';
|
||||||
import { GITHUB_API } from '@/constants';
|
import { GITHUB_API } from '@/constants';
|
||||||
import { safeExecute } from '@/utils/logger';
|
import { safeExecute } from '@/utils/logger';
|
||||||
import { Modal } from '@/components/Modal';
|
import { Modal } from '@/components/Modal';
|
||||||
|
|
@ -60,7 +61,7 @@ export const UpdateAvailableModal = ({
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size="sm"
|
size="sm"
|
||||||
title="An update is available"
|
title="An Update is Available"
|
||||||
closeOnEscape={!isDownloading && !isUpdating}
|
closeOnEscape={!isDownloading && !isUpdating}
|
||||||
>
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
|
|
@ -92,26 +93,28 @@ export const UpdateAvailableModal = ({
|
||||||
|
|
||||||
{currentVersion && (
|
{currentVersion && (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Binary: {getDisplayNameFromPath(currentVersion)}
|
Binary Type: {pretifyBinName(currentVersion.filename)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group gap="xs" align="center" mt="xs">
|
{availableUpdate?.size && (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
View release notes:
|
Update Size:{' '}
|
||||||
|
{formatDownloadSize(availableUpdate.size, availableUpdate.url)}
|
||||||
</Text>
|
</Text>
|
||||||
<Anchor
|
)}
|
||||||
size="xs"
|
|
||||||
href={`${GITHUB_API.GITHUB_BASE_URL}/${GITHUB_API.KOBOLDCPP_REPO}/releases/tag/v${availableUpdate?.version}`}
|
<Anchor
|
||||||
target="_blank"
|
size="xs"
|
||||||
rel="noopener noreferrer"
|
href={`${GITHUB_API.GITHUB_BASE_URL}/${GITHUB_API.KOBOLDCPP_REPO}/releases/tag/v${availableUpdate?.version}`}
|
||||||
>
|
target="_blank"
|
||||||
<Group gap={4} align="center">
|
rel="noopener noreferrer"
|
||||||
<span>v{availableUpdate?.version}</span>
|
>
|
||||||
<ExternalLink size={12} />
|
<Group gap={4} align="center">
|
||||||
</Group>
|
<span>View Release Notes</span>
|
||||||
</Anchor>
|
<ExternalLink size={12} />
|
||||||
</Group>
|
</Group>
|
||||||
|
</Anchor>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,15 +90,13 @@ export const App = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBinaryUpdate = async (download: DownloadItem) => {
|
const handleBinaryUpdate = async (download: DownloadItem) => {
|
||||||
const success = await handleDownload({
|
await handleDownload({
|
||||||
item: download,
|
item: download,
|
||||||
isUpdate: true,
|
isUpdate: true,
|
||||||
wasCurrentBinary: true,
|
wasCurrentBinary: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (success) {
|
closeModal();
|
||||||
closeModal();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEject = async () => {
|
const handleEject = async () => {
|
||||||
|
|
|
||||||
|
|
@ -31,21 +31,17 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
|
||||||
async (download: DownloadItem) => {
|
async (download: DownloadItem) => {
|
||||||
setDownloadingAsset(download.name);
|
setDownloadingAsset(download.name);
|
||||||
|
|
||||||
const success = await handleDownloadFromStore({
|
await handleDownloadFromStore({
|
||||||
item: download,
|
item: download,
|
||||||
isUpdate: false,
|
isUpdate: false,
|
||||||
wasCurrentBinary: false,
|
wasCurrentBinary: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (success) {
|
onDownloadComplete();
|
||||||
onDownloadComplete();
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setDownloadingAsset(null);
|
setDownloadingAsset(null);
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
|
||||||
|
|
||||||
setDownloadingAsset(null);
|
|
||||||
},
|
},
|
||||||
[handleDownloadFromStore, onDownloadComplete]
|
[handleDownloadFromStore, onDownloadComplete]
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ export const AboutTab = () => {
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card withBorder radius="md" p="xs" style={{ position: 'relative' }}>
|
<Card withBorder radius="md" p="xs" style={{ position: 'relative' }}>
|
||||||
<Tooltip label="Copy Version Info">
|
<Tooltip label="Copy Info">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -171,30 +171,26 @@ export const VersionsTab = () => {
|
||||||
const download = availableDownloads.find((d) => d.name === version.name);
|
const download = availableDownloads.find((d) => d.name === version.name);
|
||||||
if (!download) return;
|
if (!download) return;
|
||||||
|
|
||||||
const success = await handleDownloadFromStore({
|
await handleDownloadFromStore({
|
||||||
item: download,
|
item: download,
|
||||||
isUpdate: false,
|
isUpdate: false,
|
||||||
wasCurrentBinary: false,
|
wasCurrentBinary: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (success) {
|
await loadInstalledVersions();
|
||||||
await loadInstalledVersions();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdate = async (version: VersionInfo) => {
|
const handleUpdate = async (version: VersionInfo) => {
|
||||||
const download = availableDownloads.find((d) => d.name === version.name);
|
const download = availableDownloads.find((d) => d.name === version.name);
|
||||||
if (!download) return;
|
if (!download) return;
|
||||||
|
|
||||||
const success = await handleDownloadFromStore({
|
await handleDownloadFromStore({
|
||||||
item: download,
|
item: download,
|
||||||
isUpdate: true,
|
isUpdate: true,
|
||||||
wasCurrentBinary: version.isCurrent,
|
wasCurrentBinary: version.isCurrent,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (success) {
|
await loadInstalledVersions();
|
||||||
await loadInstalledVersions();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeCurrent = async (version: VersionInfo) => {
|
const makeCurrent = async (version: VersionInfo) => {
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,14 @@ export const KLITE_CSS_OVERRIDE = `
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#navbarNavDropdown {
|
#navbarNavDropdown {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#actionmenuitems {
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
#inputrow > :nth-child(1) {
|
#inputrow > :nth-child(1) {
|
||||||
padding-right: 0 !important;
|
padding-right: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ipcMain, shell, app } from 'electron';
|
import { ipcMain, app } from 'electron';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { release } from 'os';
|
import { release } from 'os';
|
||||||
import { platform, versions, arch } from 'process';
|
import { platform, versions, arch } from 'process';
|
||||||
|
|
@ -30,8 +30,8 @@ import {
|
||||||
getColorScheme,
|
getColorScheme,
|
||||||
setColorScheme,
|
setColorScheme,
|
||||||
} from '@/main/modules/config';
|
} from '@/main/modules/config';
|
||||||
import { getConfigDir } from '@/utils/node/path';
|
import { getConfigDir, openPathHandler, openUrl } from '@/utils/node/path';
|
||||||
import { logError, safeExecute } from '@/utils/node/logging';
|
import { logError } from '@/utils/node/logging';
|
||||||
import { stopFrontend as stopSillyTavernFrontend } from '@/main/modules/sillytavern';
|
import { stopFrontend as stopSillyTavernFrontend } from '@/main/modules/sillytavern';
|
||||||
import { stopFrontend as stopOpenWebUIFrontend } from '@/main/modules/openwebui';
|
import { stopFrontend as stopOpenWebUIFrontend } from '@/main/modules/openwebui';
|
||||||
import { stopFrontend as stopComfyUIFrontend } from '@/main/modules/comfyui';
|
import { stopFrontend as stopComfyUIFrontend } from '@/main/modules/comfyui';
|
||||||
|
|
@ -81,10 +81,9 @@ import type { NotepadState } from '@/types/electron';
|
||||||
export function setupIPCHandlers() {
|
export function setupIPCHandlers() {
|
||||||
const mainWindow = getMainWindow();
|
const mainWindow = getMainWindow();
|
||||||
|
|
||||||
ipcMain.handle('kobold:downloadRelease', async (_, asset) => ({
|
ipcMain.handle('kobold:downloadRelease', async (_, asset) =>
|
||||||
success: true,
|
downloadRelease(asset)
|
||||||
path: await downloadRelease(asset),
|
);
|
||||||
}));
|
|
||||||
|
|
||||||
ipcMain.handle('kobold:getInstalledVersions', () => getInstalledVersions());
|
ipcMain.handle('kobold:getInstalledVersions', () => getInstalledVersions());
|
||||||
|
|
||||||
|
|
@ -183,26 +182,13 @@ export function setupIPCHandlers() {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const openPathHandler = async (path: string) =>
|
|
||||||
(await safeExecute(async () => {
|
|
||||||
await shell.openPath(path);
|
|
||||||
return { success: true };
|
|
||||||
}, 'Failed to open path')) || {
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to open path',
|
|
||||||
};
|
|
||||||
|
|
||||||
ipcMain.handle('app:openPath', (_, path: string) => openPathHandler(path));
|
ipcMain.handle('app:openPath', (_, path: string) => openPathHandler(path));
|
||||||
|
|
||||||
ipcMain.handle('app:showLogsFolder', () => {
|
ipcMain.handle('app:showLogsFolder', () =>
|
||||||
const logsDir = join(app.getPath('userData'), 'logs');
|
openPathHandler(join(app.getPath('userData'), 'logs'))
|
||||||
return openPathHandler(logsDir);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('app:viewConfigFile', () => {
|
ipcMain.handle('app:viewConfigFile', () => openPathHandler(getConfigDir()));
|
||||||
const configDir = getConfigDir();
|
|
||||||
return openPathHandler(configDir);
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('app:minimizeWindow', () => mainWindow.minimize());
|
ipcMain.handle('app:minimizeWindow', () => mainWindow.minimize());
|
||||||
|
|
||||||
|
|
@ -233,17 +219,7 @@ export function setupIPCHandlers() {
|
||||||
setColorScheme(colorScheme)
|
setColorScheme(colorScheme)
|
||||||
);
|
);
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle('app:openExternal', async (_, url: string) => openUrl(url));
|
||||||
'app:openExternal',
|
|
||||||
async (_, url: string) =>
|
|
||||||
(await safeExecute(async () => {
|
|
||||||
await shell.openExternal(url);
|
|
||||||
return { success: true };
|
|
||||||
}, 'Failed to open external URL')) || {
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to open external URL',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
mainWindow.webContents.once('did-finish-load', async () => {
|
mainWindow.webContents.once('did-finish-load', async () => {
|
||||||
const savedZoomLevel = await getConfig('zoomLevel');
|
const savedZoomLevel = await getConfig('zoomLevel');
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import { createWriteStream } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { platform } from 'process';
|
import { platform } from 'process';
|
||||||
import { rm, unlink, rename, mkdir, chmod } from 'fs/promises';
|
import { rm, unlink, rename, mkdir, chmod } from 'fs/promises';
|
||||||
|
|
||||||
import { execa } from 'execa';
|
import { execa } from 'execa';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getInstallDir,
|
getInstallDir,
|
||||||
getCurrentKoboldBinary,
|
getCurrentKoboldBinary,
|
||||||
|
|
@ -37,27 +37,6 @@ async function removeDirectoryWithRetry(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleExistingDirectory(
|
|
||||||
unpackedDirPath: string,
|
|
||||||
isUpdate: boolean,
|
|
||||||
wasCurrentBinary: boolean
|
|
||||||
) {
|
|
||||||
if (await pathExists(unpackedDirPath)) {
|
|
||||||
if (isUpdate || wasCurrentBinary) {
|
|
||||||
try {
|
|
||||||
await removeDirectoryWithRetry(unpackedDirPath);
|
|
||||||
} catch (error) {
|
|
||||||
logError('Failed to remove existing directory:', error as Error);
|
|
||||||
throw new Error('Failed to remove existing installation');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
'Installation directory already exists. Please uninstall the existing version first.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadFile(asset: GitHubAsset, tempPackedFilePath: string) {
|
async function downloadFile(asset: GitHubAsset, tempPackedFilePath: string) {
|
||||||
const writer = createWriteStream(tempPackedFilePath);
|
const writer = createWriteStream(tempPackedFilePath);
|
||||||
const mainWindow = getMainWindow();
|
const mainWindow = getMainWindow();
|
||||||
|
|
@ -177,12 +156,16 @@ export async function downloadRelease(asset: GitHubAsset) {
|
||||||
: baseFilename;
|
: baseFilename;
|
||||||
const unpackedDirPath = join(getInstallDir(), folderName);
|
const unpackedDirPath = join(getInstallDir(), folderName);
|
||||||
|
|
||||||
|
let currentBinaryPath: string | null = null;
|
||||||
|
if (asset.isUpdate && asset.wasCurrentBinary) {
|
||||||
|
currentBinaryPath = getCurrentKoboldBinary() || null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await handleExistingDirectory(
|
if (await pathExists(unpackedDirPath)) {
|
||||||
unpackedDirPath,
|
await removeDirectoryWithRetry(unpackedDirPath);
|
||||||
Boolean(asset.isUpdate),
|
}
|
||||||
Boolean(asset.wasCurrentBinary)
|
|
||||||
);
|
|
||||||
await downloadFile(asset, tempPackedFilePath);
|
await downloadFile(asset, tempPackedFilePath);
|
||||||
|
|
||||||
await mkdir(unpackedDirPath, { recursive: true });
|
await mkdir(unpackedDirPath, { recursive: true });
|
||||||
|
|
@ -197,8 +180,15 @@ export async function downloadRelease(asset: GitHubAsset) {
|
||||||
await setCurrentKoboldBinary(launcherPath);
|
await setCurrentKoboldBinary(launcherPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentBinaryPath && asset.isUpdate && asset.wasCurrentBinary) {
|
||||||
|
const oldInstallDir = join(currentBinaryPath, '..');
|
||||||
|
|
||||||
|
if (oldInstallDir !== unpackedDirPath) {
|
||||||
|
await removeDirectoryWithRetry(oldInstallDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sendToRenderer('versions-updated');
|
sendToRenderer('versions-updated');
|
||||||
return launcherPath;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError('Failed to download or unpack binary:', error as Error);
|
logError('Failed to download or unpack binary:', error as Error);
|
||||||
throw new Error('Failed to download or unpack binary');
|
throw new Error('Failed to download or unpack binary');
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ interface KoboldVersionsState {
|
||||||
downloadProgress: Record<string, number>;
|
downloadProgress: Record<string, number>;
|
||||||
|
|
||||||
initialize: () => Promise<void>;
|
initialize: () => Promise<void>;
|
||||||
handleDownload: (params: HandleDownloadParams) => Promise<boolean>;
|
handleDownload: (params: HandleDownloadParams) => Promise<void>;
|
||||||
getLatestReleaseWithDownloadStatus: () => Promise<ReleaseWithStatus | null>;
|
getLatestReleaseWithDownloadStatus: () => Promise<ReleaseWithStatus | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -100,7 +100,7 @@ export const useKoboldVersionsStore = create<KoboldVersionsState>(
|
||||||
const { downloading } = get();
|
const { downloading } = get();
|
||||||
|
|
||||||
if (downloading) {
|
if (downloading) {
|
||||||
return false;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ downloading: item.name, downloadProgress: { [item.name]: 0 } });
|
set({ downloading: item.name, downloadProgress: { [item.name]: 0 } });
|
||||||
|
|
@ -111,25 +111,19 @@ export const useKoboldVersionsStore = create<KoboldVersionsState>(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
const asset: GitHubAsset = {
|
||||||
const asset: GitHubAsset = {
|
name: item.name,
|
||||||
name: item.name,
|
browser_download_url: item.url,
|
||||||
browser_download_url: item.url,
|
size: item.size,
|
||||||
size: item.size,
|
version: item.version,
|
||||||
version: item.version,
|
isUpdate,
|
||||||
isUpdate,
|
wasCurrentBinary,
|
||||||
wasCurrentBinary,
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const result = await window.electronAPI.kobold.downloadRelease(asset);
|
await window.electronAPI.kobold.downloadRelease(asset);
|
||||||
return result.success;
|
|
||||||
} catch (err) {
|
progressCleanup();
|
||||||
logError('Download failed:', err as Error);
|
set({ downloading: null, downloadProgress: {} });
|
||||||
return false;
|
|
||||||
} finally {
|
|
||||||
progressCleanup();
|
|
||||||
set({ downloading: null, downloadProgress: {} });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
getLatestReleaseWithDownloadStatus: async () =>
|
getLatestReleaseWithDownloadStatus: async () =>
|
||||||
safeExecute(async () => {
|
safeExecute(async () => {
|
||||||
|
|
|
||||||
4
src/types/electron.d.ts
vendored
4
src/types/electron.d.ts
vendored
|
|
@ -113,9 +113,7 @@ export interface KoboldAPI {
|
||||||
getAvailableBackends: (includeDisabled?: boolean) => Promise<BackendOption[]>;
|
getAvailableBackends: (includeDisabled?: boolean) => Promise<BackendOption[]>;
|
||||||
getCurrentInstallDir: () => Promise<string>;
|
getCurrentInstallDir: () => Promise<string>;
|
||||||
selectInstallDirectory: () => Promise<string | null>;
|
selectInstallDirectory: () => Promise<string | null>;
|
||||||
downloadRelease: (
|
downloadRelease: (asset: GitHubAsset) => Promise<void>;
|
||||||
asset: GitHubAsset
|
|
||||||
) => Promise<{ success: boolean; path?: string; error?: string }>;
|
|
||||||
launchKoboldCpp: (
|
launchKoboldCpp: (
|
||||||
args?: string[]
|
args?: string[]
|
||||||
) => Promise<{ success: boolean; pid?: number; error?: string }>;
|
) => Promise<{ success: boolean; pid?: number; error?: string }>;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { platform, resourcesPath } from 'process';
|
import { platform, resourcesPath } from 'process';
|
||||||
|
import { shell } from 'electron';
|
||||||
|
|
||||||
|
import { safeExecute } from '@/utils/logger';
|
||||||
import { PRODUCT_NAME, CONFIG_FILE_NAME } from '@/constants';
|
import { PRODUCT_NAME, CONFIG_FILE_NAME } from '@/constants';
|
||||||
import { isDevelopment } from './environment';
|
import { isDevelopment } from './environment';
|
||||||
import { pathExists } from './fs';
|
import { pathExists } from './fs';
|
||||||
|
|
@ -37,3 +40,21 @@ export async function getLauncherPath(unpackedDir: string) {
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const openPathHandler = async (path: string) =>
|
||||||
|
(await safeExecute(async () => {
|
||||||
|
await shell.openPath(path);
|
||||||
|
return { success: true };
|
||||||
|
}, 'Failed to open path')) || {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to open path',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const openUrl = (url: string) =>
|
||||||
|
safeExecute(async () => {
|
||||||
|
await shell.openExternal(url);
|
||||||
|
return { success: true };
|
||||||
|
}, 'Failed to open external URL') || {
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to open external URL',
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue