diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte
index 12d8c71f..16172f5e 100644
--- a/src/lib/components/Sidebar.svelte
+++ b/src/lib/components/Sidebar.svelte
@@ -690,7 +690,7 @@
{
diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte
index f26f1cc6..1593883e 100644
--- a/src/lib/components/StudioShell.svelte
+++ b/src/lib/components/StudioShell.svelte
@@ -310,6 +310,10 @@
/** @type {StudioTab[]} */
let tabs = $state([])
+ // Recently-closed tabs (most-recent last), for Reopen Closed Tab (Ctrl/⌘+Shift+T).
+ // Stores lightweight descriptors, not live tab objects; capped so it can't grow.
+ let closedTabStack = $state(/** @type {any[]} */ ([]))
+ const CLOSED_TAB_STACK_MAX = 20
let activeTabId = $state(/** @type {string | null} */ (null))
// ── Split-pane / editor-group layout ─────────────────────────────────────
@@ -1283,11 +1287,44 @@ let rowSearch = $state('')
toggleStatusBar()
})
+ // Reopen the most recently closed tab (browser-style).
createHotkey('Mod+Shift+T', (e) => {
+ if (!connection) return
+ e.preventDefault()
+ reopenLastClosedTab()
+ })
+
+ // Tab-bar visibility toggle moved here so Mod+Shift+T can reopen closed tabs.
+ createHotkey('Alt+Shift+T', (e) => {
e.preventDefault()
toggleTabBar()
})
+ // Disconnect the current connection (opens the confirm dialog).
+ createHotkey('Mod+Alt+D', (e) => {
+ if (!connection) return
+ e.preventDefault()
+ showDisconnectDialog = true
+ })
+
+ // Open the keyboard-shortcuts reference (Ctrl/⌘+/ — same key as Mod+? without shift).
+ createHotkey('Mod+/', (e) => {
+ if (commandOpen || showConnectionModal || showSettingsModal || showShortcutsModal) return
+ e.preventDefault()
+ showShortcutsModal = true
+ })
+
+ // Jump straight to tab N (Ctrl/⌘+1..8); 9 always jumps to the last tab —
+ // the same convention as browsers and editors.
+ for (let n = 1; n <= 9; n++) {
+ createHotkey(`Mod+${n}`, (e) => {
+ if (!connection || tabs.length === 0) return
+ e.preventDefault()
+ const idx = n === 9 ? tabs.length - 1 : Math.min(n - 1, tabs.length - 1)
+ void activateTab(tabs[idx].id)
+ })
+ }
+
createHotkey('Mod+Shift+L', (e) => {
e.preventDefault()
openLogsTab()
@@ -1977,6 +2014,7 @@ let rowSearch = $state('')
const key = tabTableKey(closing)
if (key) clearPendingChanges(key)
}
+ rememberClosedTab(closing)
const nextTabs = tabs.filter((t) => t.id !== id)
if (nextTabs.length === 0) {
tabs = [createWelcomeTab()]
@@ -1991,6 +2029,28 @@ let rowSearch = $state('')
}
}
+ /** Push a closed tab onto the reopen stack (welcome tabs aren't worth restoring). */
+ function rememberClosedTab(tab) {
+ if (!tab || tab.kind === 'welcome') return
+ // Snapshot with a shallow state clone so later edits to the live tree can't
+ // mutate what we'll restore. `id`/`pinned` are dropped — reopen mints fresh.
+ const { id: _id, pinned: _pinned, ...rest } = tab
+ const snapshot = { ...rest, state: tab.state ? { ...tab.state } : tab.state }
+ closedTabStack = [...closedTabStack, snapshot].slice(-CLOSED_TAB_STACK_MAX)
+ }
+
+ /** Ctrl/⌘+Shift+T — reopen the most recently closed tab. */
+ function reopenLastClosedTab() {
+ const entry = closedTabStack[closedTabStack.length - 1]
+ if (!entry) return
+ closedTabStack = closedTabStack.slice(0, -1)
+ saveActiveTabState()
+ dropWelcomeTabs()
+ const tab = { ...entry, id: crypto.randomUUID(), pinned: false, state: entry.state ? { ...entry.state } : entry.state }
+ tabs = [...tabs, tab]
+ void activateTab(tab.id)
+ }
+
/** @param {string} id — keep this tab (and pinned tabs), close everything else */
async function closeOtherTabs(id) {
const keep = tabs.find((t) => t.id === id)
diff --git a/src/lib/components/UpdateDialog.svelte b/src/lib/components/UpdateDialog.svelte
index 07811727..61c5d9bc 100644
--- a/src/lib/components/UpdateDialog.svelte
+++ b/src/lib/components/UpdateDialog.svelte
@@ -178,13 +178,16 @@
await invoke('restart_app')
}
- /** Open the online changelog in the user's browser. */
+ // Tagged so web analytics can attribute changelog views to the desktop app.
+ const CHANGELOG_URL = 'https://stroke.click/changelog?utm_source=stroke-app&utm_medium=update-dialog&utm_campaign=changelog'
+
+ /** Open the online changelog in the user's browser (never the in-app tab). */
async function openChangelog() {
try {
const { openUrl } = await import('@tauri-apps/plugin-opener')
- await openUrl('https://stroke.click/changelog')
+ await openUrl(CHANGELOG_URL)
} catch {
- window.open('https://stroke.click/changelog', '_blank', 'noopener,noreferrer')
+ window.open(CHANGELOG_URL, '_blank', 'noopener,noreferrer')
}
}
@@ -325,17 +328,18 @@
{errorMsg}
{:else if status === 'up-to-date' || checking}
-
+
{checking ? 'Checking GitHub for a newer release…' : "You're on the latest version."}
{#if !checking}
{/if}
{/if}
diff --git a/src/lib/stores/settings.js b/src/lib/stores/settings.js
index 054f3b0d..89eb4066 100644
--- a/src/lib/stores/settings.js
+++ b/src/lib/stores/settings.js
@@ -14,7 +14,7 @@ const STORAGE_KEY = 'stroke:settings'
/** @typedef {'geist' | 'serif' | 'apple'} FontId */
/** @typedef {'regular' | 'light' | 'bold'} IconStyleId */
/** @typedef {'lucide' | 'hugeicons'} IconSetId */
-/** @typedef {{ theme: ThemeId, zoom: number, font: FontId, iconStyle: IconStyleId, iconSet: IconSetId, mcpAutoStart: boolean, launchAtLogin: boolean, autoReconnectOnStartup: boolean, previewDmlBeforeApply: boolean }} AppSettings */
+/** @typedef {{ theme: ThemeId, zoom: number, font: FontId, iconStyle: IconStyleId, iconSet: IconSetId, tableStyle: TableStyleId, mcpAutoStart: boolean, launchAtLogin: boolean, autoReconnectOnStartup: boolean, previewDmlBeforeApply: boolean }} AppSettings */
/** UI zoom scale (font + layout). 1 = 100%. */
export const ZOOM_STEPS = [0.8, 0.85, 0.9, 0.95, 1, 1.05, 1.1, 1.15, 1.25, 1.5]
@@ -89,6 +89,36 @@ function normalizeIconSet(/** @type {unknown} */ id) {
return ICON_SETS[/** @type {IconSetId} */ (id)] ? /** @type {IconSetId} */ (id) : DEFAULT_ICON_SET
}
+/**
+ * @typedef {'lines'|'dotted'|'dots'|'minimal'|'bordered'|'striped'} TableStyleId
+ * @typedef {{ label: string, description: string,
+ * rows: boolean, cols: boolean, dash: number[]|null, dots: boolean, strong?: boolean, zebra?: boolean }} TableStyleDef
+ */
+
+/**
+ * Data-grid style presets for the canvas table. Each preset only changes how the
+ * per-row grid pass draws separators — it's applied in DataTable's virtualized
+ * draw(), so it costs O(visible cells) and never scales with total row count.
+ * - rows/cols: draw horizontal / vertical separators
+ * - dash: canvas setLineDash pattern (null = solid)
+ * - dots: draw a small dot at each cell join instead of lines
+ * @type {Record
}
+ */
+export const TABLE_STYLES = {
+ lines: { label: 'Lines', description: 'Solid grid lines (classic)', rows: true, cols: true, dash: null, dots: false },
+ bordered: { label: 'Bordered', description: 'Bold high-contrast grid lines', rows: true, cols: true, dash: null, dots: false, strong: true },
+ striped: { label: 'Striped', description: 'Alternating even/odd row shading', rows: true, cols: false, dash: null, dots: false, zebra: true },
+ dotted: { label: 'Dotted', description: 'Fine dotted grid, softer feel', rows: true, cols: true, dash: [1, 3], dots: false },
+ dots: { label: 'Dots', description: 'Corner dots + soft row shading', rows: false, cols: false, dash: null, dots: true, zebra: true },
+ minimal: { label: 'Minimal', description: 'Row separators only, no columns', rows: true, cols: false, dash: null, dots: false },
+}
+/** @type {TableStyleId} */
+export const DEFAULT_TABLE_STYLE = 'lines'
+/** @returns {TableStyleId} */
+export function normalizeTableStyle(/** @type {unknown} */ id) {
+ return TABLE_STYLES[/** @type {TableStyleId} */ (id)] ? /** @type {TableStyleId} */ (id) : DEFAULT_TABLE_STYLE
+}
+
/** @type {AppSettings} */
export const DEFAULT_SETTINGS = {
theme: DEFAULT_THEME_ID,
@@ -96,6 +126,7 @@ export const DEFAULT_SETTINGS = {
font: DEFAULT_FONT,
iconStyle: DEFAULT_ICON_STYLE,
iconSet: DEFAULT_ICON_SET,
+ tableStyle: DEFAULT_TABLE_STYLE,
mcpAutoStart: false,
launchAtLogin: false,
autoReconnectOnStartup: true,
@@ -121,6 +152,10 @@ export const appThemeId = writable(/** @type {ThemeId} */ (DEFAULT_THEME_ID))
/** Reactive: show a SQL preview/confirm before applying grid writes (synced by applySettings). */
export const appPreviewDml = writable(true)
+/** Reactive canvas-table grid style preset (synced by applySettings). DataTable
+ * subscribes to repaint when it changes. */
+export const appTableStyle = writable(/** @type {TableStyleId} */ (DEFAULT_TABLE_STYLE))
+
const LAST_DARK_KEY = 'stroke:last-dark-theme'
const LAST_LIGHT_KEY = 'stroke:last-light-theme'
@@ -192,7 +227,8 @@ export function loadSettings() {
const font = normalizeFont(parsed.font)
const iconStyle = normalizeIconStyle(parsed.iconStyle)
const iconSet = normalizeIconSet(parsed.iconSet)
- _settingsCache = { theme, zoom, font, iconStyle, iconSet, mcpAutoStart, launchAtLogin, autoReconnectOnStartup, previewDmlBeforeApply }
+ const tableStyle = normalizeTableStyle(parsed.tableStyle)
+ _settingsCache = { theme, zoom, font, iconStyle, iconSet, tableStyle, mcpAutoStart, launchAtLogin, autoReconnectOnStartup, previewDmlBeforeApply }
return { ..._settingsCache }
} catch {
return { ...DEFAULT_SETTINGS }
@@ -261,6 +297,12 @@ export function applySettings(settings) {
// Grid-write DML preview toggle — DataTable subscribes to gate its confirm dialog.
appPreviewDml.set(settings.previewDmlBeforeApply !== false)
+ // Canvas table grid style — data attribute for any CSS hooks; DataTable reads
+ // the store and repaints the virtualized grid pass.
+ const tableStyle = normalizeTableStyle(settings.tableStyle)
+ root.setAttribute('data-table-style', tableStyle)
+ appTableStyle.set(tableStyle)
+
// Keep the canvas-table zoom in lockstep with the app zoom so Cmd +/-/0 (and
// the zoom buttons) scale the grid alongside the rest of the UI. The canvas
// renderer reads zoomState directly and repaints on change.