Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
977ef37
fix(diagrams): disable font ligatures in Mermaid code editor
broisnischal Jul 4, 2026
0f1a606
feat:: dml
broisnischal Jul 4, 2026
529ec57
feat(data): staged deletes, edit persistence, close guards, export pr…
broisnischal Jul 4, 2026
4275d80
feat(data-table): rectangular block cell selection
broisnischal Jul 4, 2026
226a7f6
feat(workspace): VS Code-style split-pane / editor-group layout
broisnischal Jul 4, 2026
a6f2715
refactor(workspace): route Mod+W / editor close through group-aware c…
broisnischal Jul 4, 2026
c06c010
fix(data-table): scroll jitter, pinch-zoom blur, JSON-expand vibratio…
broisnischal Jul 4, 2026
5b8ac82
merge: split-pane / editor-group layout + integrate with existing fea…
broisnischal Jul 4, 2026
545b21d
fix(data-table): focus grid after drag-select so Cmd+C copies the range
broisnischal Jul 4, 2026
9929353
polish(block-select): hide per-cell focus ring during range, uniform …
broisnischal Jul 4, 2026
bdcbe54
chore(vite): relax dev-server fs allow-list (worktree parent node_mod…
broisnischal Jul 4, 2026
18b23b3
feat(split-panes): pointer-based tab drag with floating ghost + drop-…
broisnischal Jul 4, 2026
53595ad
feat(toolbar): container-query responsiveness for narrow (split) panes
broisnischal Jul 4, 2026
41cccd6
polish(split-panes): grip splitter, focused-pane accent, toolbar spac…
broisnischal Jul 4, 2026
699d704
polish(sql-history-panel): cleaner segmented tab bar (spacing, active…
broisnischal Jul 4, 2026
7514b72
fix(block-select): reliable Cmd+C copy
broisnischal Jul 4, 2026
6cb51af
feat(pagination): persist rows-per-page as the session-wide default
broisnischal Jul 4, 2026
b43080e
feat(settings): 'Reduce motion' toggle to disable subtle animations/t…
broisnischal Jul 4, 2026
fd8685b
fix(data-table): compute cell range via plain fn, not $derived
broisnischal Jul 4, 2026
cd19d23
Revert "feat(settings): 'Reduce motion' toggle to disable subtle anim…
broisnischal Jul 4, 2026
db3e0e1
chore(release): v1.3.0
broisnischal Jul 4, 2026
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "stroke",
"private": true,
"version": "1.2.0",
"version": "1.3.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stroke",
"version": "1.2.0",
"version": "1.3.0",
"identifier": "com.broisnischal.stroke",
"build": {
"frontendDist": "../dist",
Expand Down
25 changes: 20 additions & 5 deletions src/lib/components/AiChat.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -1295,6 +1295,8 @@

/** IDs of chart items the user has already saved this session */
let savedChartIds = $state(/** @type {Set<string>} */ (new Set()));
/** IDs of diagram items the user has explicitly saved this session */
let savedDiagramIds = $state(/** @type {Set<string>} */ (new Set()));

/** Blocks collapsed by user (in set = collapsed; default open for both code and SQL) */
let collapsed = $state(/** @type {Set<string>} */ (new Set()));
Expand Down Expand Up @@ -1931,7 +1933,6 @@
if (execIdx >= 0) items.splice(execIdx, 1);
toolResult = JSON.stringify({ error: "No diagram code provided." });
} else {
saveDiagram(title, code);
const diagId = uid();
const execIdx = items.findIndex((i) => i.id === execId);
const diagItem = /** @type {ChatItem} */ ({
Expand All @@ -1947,7 +1948,7 @@
success: true,
title,
diagramType,
message: "Diagram rendered and saved to Diagrams library.",
message: "Diagram rendered. The user can save it to the Diagrams library.",
});
}
} else if (call.function.name === "list_tables") {
Expand Down Expand Up @@ -3287,9 +3288,23 @@
>
<button
type="button"
class="inline-flex size-5 items-center justify-center rounded text-emerald-500/70"
title="Saved to Diagrams library"
><Check class="size-3" strokeWidth={2.5} /></button
class={savedDiagramIds.has(item.id)
? "inline-flex size-5 items-center justify-center rounded text-emerald-500/70"
: "inline-flex h-5 items-center gap-1 rounded px-1.5 text-[10px] text-muted-foreground hover:bg-accent hover:text-foreground"}
title={savedDiagramIds.has(item.id) ? "Saved to Diagrams library" : "Save to Diagrams library"}
disabled={savedDiagramIds.has(item.id)}
onclick={() => {
if (savedDiagramIds.has(item.id)) return;
saveDiagram(item.title || "Diagram", item.code);
savedDiagramIds = new Set([...savedDiagramIds, item.id]);
toast.success("Diagram saved", {
description: "View it in the Diagrams page",
action: onopendiagramspage
? { label: "Open", onClick: onopendiagramspage }
: undefined,
});
}}
>{#if savedDiagramIds.has(item.id)}<Check class="size-3" strokeWidth={2.5} />{:else}<Save class="size-3" />Save{/if}</button
>
</div>
</div>
Expand Down
727 changes: 628 additions & 99 deletions src/lib/components/DataTable.svelte

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/lib/components/DiagramsPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@
<textarea
bind:value={draftCode}
spellcheck="false"
class="min-h-0 flex-1 resize-none rounded-lg border border-border/50 bg-background/40 p-3 font-mono text-ui-sm text-foreground placeholder:text-muted-foreground/30 focus:border-border/80 focus:outline-none"
class="min-h-0 flex-1 resize-none rounded-lg border border-border/50 bg-background/40 p-3 font-mono text-ui-sm text-foreground placeholder:text-muted-foreground/30 focus:border-border/80 focus:outline-none [font-feature-settings:'liga'_0,'calt'_0] [font-variant-ligatures:none]"
></textarea>
</div>
<div class="flex w-1/2 min-w-0 flex-col overflow-hidden">
Expand Down
1 change: 1 addition & 0 deletions src/lib/components/FkSubviewPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
class="overflow-x-auto"
class:overflow-y-auto={needsScroll}
style={needsScroll ? 'max-height: 280px' : ''}
data-fk-subview-scroll
>
<table class="w-max min-w-full border-collapse">
<thead class="sticky top-0 z-10 bg-background">
Expand Down
144 changes: 144 additions & 0 deletions src/lib/components/PaneLayout.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<script>
import Self from './PaneLayout.svelte'
import { cn } from '$lib/utils.js'

/** @typedef {import('$lib/pane-layout.js').PaneNode} PaneNode */
/** @typedef {import('$lib/pane-layout.js').GroupNode} GroupNode */
/** @typedef {import('$lib/pane-layout.js').DropEdge} DropEdge */

let {
/** @type {PaneNode} */
node,
/** @type {string | null} */
focusedGroupId = null,
/** True when >1 pane exists — enables the focused-pane accent (noise when single). */
multiPane = false,
/** Current pointer-drag drop target (group + edge), or null. Drives the hint. */
dropTarget = /** @type {{ groupId: string, edge: DropEdge } | null} */ (null),
/** @type {import('svelte').Snippet<[GroupNode, boolean]>} */
renderGroup,
/** @type {(splitId: string, sizes: [number, number]) => void} */
onresize = () => {},
/** @type {(groupId: string) => void} */
onfocusgroup = () => {},
} = $props()

/** @type {HTMLElement | null} */
let splitEl = $state(null)

/** @param {PointerEvent} e */
function startResize(e) {
if (!splitEl || node.type !== 'split') return
e.preventDefault()
const rect = splitEl.getBoundingClientRect()
const horizontal = node.dir === 'row'
const splitId = node.id
/** @param {PointerEvent} ev */
const move = (ev) => {
const total = horizontal ? rect.width : rect.height
if (total <= 0) return
const pos = horizontal ? ev.clientX - rect.left : ev.clientY - rect.top
let pct = (pos / total) * 100
pct = Math.min(90, Math.max(10, pct))
onresize(splitId, [pct, 100 - pct])
}
const up = () => {
window.removeEventListener('pointermove', move)
window.removeEventListener('pointerup', up)
document.body.style.userSelect = ''
document.body.style.cursor = ''
}
document.body.style.userSelect = 'none'
document.body.style.cursor = horizontal ? 'col-resize' : 'row-resize'
window.addEventListener('pointermove', move)
window.addEventListener('pointerup', up)
}

/** Position/size of the drop-hint overlay for a given edge (translucent preview). */
const HINT_CLASS = /** @type {Record<DropEdge, string>} */ ({
left: 'left-0 top-0 h-full w-1/2',
right: 'right-0 top-0 h-full w-1/2',
top: 'left-0 top-0 w-full h-1/2',
bottom: 'left-0 bottom-0 w-full h-1/2',
center: 'inset-0',
})
</script>

{#if node.type === 'split'}
<div
bind:this={splitEl}
class={cn('flex min-h-0 min-w-0 flex-1', node.dir === 'row' ? 'flex-row' : 'flex-col')}
>
<div class="flex min-h-0 min-w-0 flex-col" style="flex: 0 0 {node.sizes[0]}%">
<Self node={node.children[0]} {focusedGroupId} {multiPane} {dropTarget} {renderGroup} {onresize} {onfocusgroup} />
</div>

<!-- Splitter: a slim track with an always-visible hairline, a hover-revealed
grip, and a widened invisible hit area for easy grabbing. -->
<div
class={cn(
'group/split relative z-10 shrink-0',
node.dir === 'row' ? 'w-1 cursor-col-resize' : 'h-1 cursor-row-resize',
)}
onpointerdown={startResize}
role="separator"
aria-orientation={node.dir === 'row' ? 'vertical' : 'horizontal'}
tabindex="-1"
>
<!-- hairline (always visible), brightens on hover/drag -->
<div
class={cn(
'absolute bg-border/60 transition-colors group-hover/split:bg-primary/70 group-active/split:bg-primary',
node.dir === 'row' ? 'inset-y-0 left-1/2 w-px -translate-x-1/2' : 'inset-x-0 top-1/2 h-px -translate-y-1/2',
)}
></div>
<!-- grip bar, revealed on hover -->
<div
class={cn(
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/80 opacity-0 transition-opacity group-hover/split:opacity-100',
node.dir === 'row' ? 'h-8 w-0.5' : 'h-0.5 w-8',
)}
></div>
<!-- widened invisible hit area -->
<div
class={cn(
'absolute',
node.dir === 'row' ? '-left-1.5 -right-1.5 inset-y-0' : '-top-1.5 -bottom-1.5 inset-x-0',
)}
></div>
</div>

<div class="flex min-h-0 min-w-0 flex-col" style="flex: 0 0 {node.sizes[1]}%">
<Self node={node.children[1]} {focusedGroupId} {multiPane} {dropTarget} {renderGroup} {onresize} {onfocusgroup} />
</div>
</div>
{:else}
{@const isFocused = node.id === focusedGroupId}
{@const hintEdge = dropTarget && dropTarget.groupId === node.id ? dropTarget.edge : null}
<!-- data-pane-group lets the drag orchestrator hit-test which group + edge the
pointer is over via elementFromPoint. -->
<div
class="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
data-pane-group={node.id}
role="group"
onpointerdowncapture={() => onfocusgroup(node.id)}
>
<!-- Focused-pane accent — only when split, so a lone pane isn't decorated. -->
{#if multiPane && isFocused}
<div class="pointer-events-none absolute inset-x-0 top-0 z-30 h-0.5 bg-primary/60"></div>
{/if}
{@render renderGroup(node, isFocused)}

<!-- Drop hint: translucent preview of where the dragged tab will land. -->
{#if hintEdge}
<div class="pointer-events-none absolute inset-0 z-40">
<div
class={cn(
'absolute rounded-md bg-primary/20 ring-2 ring-inset ring-primary/70 transition-all duration-75',
HINT_CLASS[hintEdge],
)}
></div>
</div>
{/if}
</div>
{/if}
71 changes: 71 additions & 0 deletions src/lib/components/PaneSnapshot.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<script>
import DataTable from './DataTable.svelte'
import Table2 from '@lucide/svelte/icons/table-2'
import PanelLeft from '@lucide/svelte/icons/panel-left'
import { tabDisplayTitle } from '$lib/studio-tabs.js'

/** @typedef {import('$lib/studio-tabs.js').StudioTab} StudioTab */
/** @typedef {import('$lib/studio-tabs.js').TableTabState} TableTabState */

let {
/** @type {StudioTab | null} */
tab = null,
} = $props()

const tableState = $derived(
tab?.kind === 'table' && tab.state ? /** @type {TableTabState} */ (tab.state) : null,
)

// Local, throwaway bindable state so DataTable's editing/selection machinery
// has somewhere to write. Background panes are read-only, so nothing here is
// persisted back to the tab.
let selected = $state(new Set())
let focusedRow = $state(/** @type {number | null} */ (null))
let inspectorRow = $state(/** @type {number | null} */ (null))
let editingCell = $state(/** @type {any} */ (null))
</script>

{#if tableState}
{#if !tableState.table}
<div class="flex flex-1 flex-col items-center justify-center gap-2 p-8 text-center">
<Table2 class="size-6 text-muted-foreground/30" />
<p class="font-mono text-ui-xs text-muted-foreground/60">No table</p>
</div>
{:else if tableState.columns.length === 0}
<div class="flex flex-1 flex-col items-center justify-center gap-2 p-8 text-center">
<Table2 class="size-6 text-muted-foreground/30" />
<p class="font-mono text-ui-xs text-muted-foreground/60">Focus this pane to load {tableState.table}</p>
</div>
{:else}
<div class="flex min-h-0 min-w-0 flex-1">
<DataTable
columns={tableState.columns}
rows={tableState.rows}
primaryKey={tableState.primaryKey}
foreignKeys={tableState.foreignKeys}
schema={tableState.schema}
tableName={tableState.table}
hiddenColumns={tableState.hiddenColumns}
rowSort={tableState.rowSort}
searchQuery={tableState.rowSearch}
columnWidthsKey={`${tableState.schema}.${tableState.table}`}
loading={false}
readonly={true}
bind:selected
bind:focusedRow
bind:inspectorRow
bind:editingCell
/>
</div>
{/if}
{:else}
<div class="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
<PanelLeft class="size-7 text-muted-foreground/25" />
<div class="space-y-1">
<p class="text-ui-sm font-medium text-foreground/70">{tab ? tabDisplayTitle(tab) : 'Empty pane'}</p>
<p class="max-w-xs text-ui-xs text-muted-foreground/60">
Click this pane to focus it and interact with this view.
</p>
</div>
</div>
{/if}
30 changes: 20 additions & 10 deletions src/lib/components/QueryHistoryPanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,8 @@
{#if visible}
<aside class="flex w-56 shrink-0 flex-col border-r border-border bg-panel">

<!-- Header row: title + close -->
<div class="flex h-9 shrink-0 items-center justify-between border-b border-border pl-2 pr-2">
<!-- Header row: segmented tabs + hide-panel toggle -->
<div class="flex h-9 shrink-0 items-center justify-between gap-1 border-b border-border pl-1 pr-1.5">
<div class="flex h-full min-w-0 items-stretch">
{#each panelTabs as t (t.id)}
{@const Icon = t.icon}
Expand All @@ -125,21 +125,31 @@
aria-current={active ? 'page' : undefined}
onclick={() => (tab = t.id)}
class={cn(
'relative flex h-full shrink-0 items-center gap-1 px-1 transition-colors',
active ? 'text-foreground' : 'text-muted-foreground/50 hover:text-muted-foreground',
'group/pt relative flex h-full shrink-0 items-center gap-1.5 px-2.5 text-[11px] transition-colors',
active
? 'font-medium text-foreground'
: 'font-normal text-muted-foreground/55 hover:text-foreground',
)}
>
{#if active}
<span class="absolute inset-x-1 bottom-0 h-px bg-primary" aria-hidden="true"></span>
{/if}
<Icon class="size-3 shrink-0" aria-hidden="true" />
<span class="text-[11px] font-normal leading-none">{t.label}</span>
<Icon
class={cn('size-3.5 shrink-0 transition-opacity', active ? 'opacity-90' : 'opacity-55 group-hover/pt:opacity-80')}
aria-hidden="true"
/>
<span class="leading-none">{t.label}</span>
<!-- active underline, aligned to the header's bottom border -->
<span
class={cn(
'pointer-events-none absolute inset-x-1.5 -bottom-px h-0.5 rounded-full bg-primary transition-opacity',
active ? 'opacity-100' : 'opacity-0',
)}
aria-hidden="true"
></span>
</button>
{/each}
</div>
<button
type="button"
class="inline-flex size-5 items-center justify-center rounded text-muted-foreground/50 transition-colors hover:bg-muted/60 hover:text-foreground"
class="inline-flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 transition-colors hover:bg-muted/60 hover:text-foreground"
title="Hide panel ({modKey}⇧B)"
onclick={onclose}
>
Expand Down
5 changes: 5 additions & 0 deletions src/lib/components/SettingsDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@
settings = updateSettings({ autoReconnectOnStartup: !settings.autoReconnectOnStartup });
}

function togglePreviewDml() {
settings = updateSettings({ previewDmlBeforeApply: !settings.previewDmlBeforeApply });
}

/** @type {boolean | null} */
let launchAtLogin = $state(null);

Expand Down Expand Up @@ -329,6 +333,7 @@
<div class="divide-y divide-border/50 rounded-lg border border-border/70 bg-card/40">
{@render toggleRow('Launch at login', 'Start Stroke when you sign in', launchAtLogin ?? false, toggleLaunchAtLogin)}
{@render toggleRow('Auto reconnect on startup', 'Reconnect to the last database on launch', settings.autoReconnectOnStartup, toggleAutoReconnect)}
{@render toggleRow('Preview SQL before applying', 'Review the DML before edits, inserts, and deletes run', settings.previewDmlBeforeApply, togglePreviewDml)}
{@render toggleRow('MCP auto-start', 'Start the MCP server on database connect', settings.mcpAutoStart, toggleMcpAutoStart)}
</div>
</section>
Expand Down
Loading
Loading