Merge pull request #2641 from beekeeper-studio/feature/2379-Tab-History

Feature/2379 tab history
This commit is contained in:
Matthew Rathbone
2025-03-16 11:37:34 -05:00
committed by GitHub
14 changed files with 266 additions and 62 deletions

View File

@@ -13,6 +13,7 @@ import { GeneratorHandlers } from '@/handlers/generatorHandlers';
import { Handlers } from '../backend/handlers/handlers';
import { newState, removeState, state } from '@/handlers/handlerState';
import { QueryHandlers } from '@/handlers/queryHandlers';
import { TabHistoryHandlers } from '@/handlers/tabHistoryHandlers'
import { ExportHandlers } from '@commercial/backend/handlers/exportHandlers';
import { BackupHandlers } from '@commercial/backend/handlers/backupHandlers';
import { ImportHandlers } from '@commercial/backend/handlers/importHandlers';
@@ -50,6 +51,7 @@ export const handlers: Handlers = {
...EnumHandlers,
...TempHandlers,
...LicenseHandlers,
...TabHistoryHandlers,
...(platformInfo.isDevelopment && DevHandlers),
};

View File

@@ -8,7 +8,6 @@ import { LoggerOptions } from 'typeorm/logger/LoggerOptions'
import { PinnedEntity } from "./models/PinnedEntity"
import { CloudCredential } from "./models/CloudCredential"
import { OpenTab } from "./models/OpenTab"
import { LicenseKey } from "./models/LicenseKey"
import { HiddenEntity } from "./models/HiddenEntity"
import { HiddenSchema } from "./models/HiddenSchema"

View File

@@ -1,8 +1,8 @@
import _ from 'lodash'
import ISavedQuery from "@/common/interfaces/ISavedQuery";
import { TableFilter, TableOrView } from "@/lib/db/models";
import { Column, Entity } from "typeorm";
import { Column, Entity, LessThan, Not, IsNull, DeleteDateColumn } from "typeorm";
import { ApplicationEntity } from "./application_entity";
import _ from 'lodash'
import { TransportOpenTab } from "@/common/transport/TransportOpenTab";
@@ -10,6 +10,10 @@ type TabType = 'query' | 'table' | 'table-properties' | 'settings' | 'table-buil
const pickable = ['title', 'tabType', 'unsavedChanges', 'unsavedQueryText', 'tableName', 'schemaName', 'entityType', 'titleScope', 'connectionId', 'workspaceId', 'position']
interface ConnectionIds {
connectionId: number,
workspaceId: number
}
@Entity({ name: 'tabs'})
export class OpenTab extends ApplicationEntity {
@@ -74,6 +78,12 @@ export class OpenTab extends ApplicationEntity {
filters?: string
isRunning = false;
@Column({type: 'datetime', nullable: true})
lastActive?: Date
@DeleteDateColumn()
deletedAt?: Date
public setFilters(filters: Nullable<TableFilter[]>) {
if (filters && _.isArray(filters)) {
this.filters = JSON.stringify(filters)
@@ -156,5 +166,45 @@ export class OpenTab extends ApplicationEntity {
}
}
}
static async getHistory(connectionIds: ConnectionIds, limit = 10): Promise<TransportOpenTab[]> {
const { connectionId, workspaceId } = connectionIds
return await this.find({
where: {
connectionId,
workspaceId
},
order: {
lastActive: 'DESC'
},
take: limit,
withDeleted: true
})
}
static async clearOldDeletedTabs(connectionIds: ConnectionIds, xDays: number): Promise<void> {
const { connectionId, workspaceId } = connectionIds
const deletedAtThreshold = new Date()
deletedAtThreshold.setDate(deletedAtThreshold.getDate() - xDays)
await this.delete({
connectionId,
workspaceId,
deletedAt: LessThan(deletedAtThreshold)
})
}
static async getClosedHistory(connectionIds: ConnectionIds): Promise<TransportOpenTab> {
const { connectionId, workspaceId } = connectionIds
return await this.findOne({
where: {
deletedAt: Not(IsNull()),
connectionId,
workspaceId
},
order: {
deletedAt: 'DESC'
},
withDeleted: true
})
}
}

View File

@@ -23,6 +23,8 @@ export interface TransportOpenTab extends Transport {
connectionId: number,
workspaceId?: number,
filters?: string,
lastActive?: Date|null,
deletedAt?: Date|null
isRunning: boolean, // not on the actual model, but used in frontend
}

View File

@@ -17,7 +17,7 @@
:key="tab.id"
:tab="tab"
:tabs-count="tabItems.length"
:selected="activeTab === tab"
:selected="activeTab.id === tab.id"
@click="click"
@close="close"
@closeAll="closeAll"
@@ -55,12 +55,12 @@
class="tab-pane"
:id="'tab-' + idx"
:key="tab.id"
:class="{active: (activeTab === tab)}"
v-show="activeTab === tab"
:class="{active: (activeTab.id === tab.id)}"
v-show="activeTab.id === tab.id"
>
<QueryEditor
v-if="tab.tabType === 'query'"
:active="activeTab === tab"
:active="activeTab.id === tab.id"
:tab="tab"
:tab-id="tab.id"
/>
@@ -72,7 +72,7 @@
<template v-slot:default="slotProps">
<TableTable
:tab="tab"
:active="activeTab === tab"
:active="activeTab.id === tab.id"
:table="slotProps.table"
/>
</template>
@@ -84,7 +84,7 @@
>
<template v-slot:default="slotProps">
<TableProperties
:active="activeTab === tab"
:active="activeTab.id === tab.id"
:tab="tab"
:tab-id="tab.id"
:table="slotProps.table"
@@ -93,7 +93,7 @@
</tab-with-table>
<TableBuilder
v-if="tab.tabType === 'table-builder'"
:active="activeTab === tab"
:active="activeTab.id === tab.id"
:tab="tab"
:tab-id="tab.id"
/>
@@ -107,7 +107,7 @@
v-if="tab.tabType === 'backup'"
:connection="connection"
:is-restore="false"
:active="activeTab === tab"
:active="activeTab.id === tab.id"
:tab="tab"
@close="close"
/>
@@ -115,7 +115,7 @@
v-if="tab.tabType === 'restore'"
:connection="connection"
:is-restore="true"
:active="activeTab === tab"
:active="activeTab.id === tab.id"
:tab="tab"
@close="close"
/>
@@ -265,7 +265,7 @@ import { AppEvent } from '../common/AppEvent'
import { mapGetters, mapState } from 'vuex'
import Draggable from 'vuedraggable'
import ShortcutHints from './editor/ShortcutHints.vue'
import { FormatterDialect, DialectTitles } from '@shared/lib/dialects/models'
import { FormatterDialect } from '@shared/lib/dialects/models'
import Vue from 'vue';
import { CloseTabOptions } from '@/common/appdb/models/CloseTab';
import TabWithTable from './common/TabWithTable.vue';
@@ -277,7 +277,6 @@ import { readWebFile } from '@/common/utils'
import Noty from 'noty'
import ConfirmationModal from './common/modals/ConfirmationModal.vue'
import SqlFilesImportModal from '@/components/common/modals/SqlFilesImportModal.vue'
import DetailViewSidebar from '@/components/sidebar/DetailViewSidebar.vue'
import { safeSqlFormat as safeFormat } from '@/common/utils';
import { TransportOpenTab, setFilters, matches, duplicate } from '@/common/transport/TransportOpenTab'
@@ -301,7 +300,6 @@ import { TransportOpenTab, setFilters, matches, duplicate } from '@/common/trans
PendingChangesButton,
ConfirmationModal,
SqlFilesImportModal,
DetailViewSidebar,
},
data() {
return {
@@ -401,7 +399,7 @@ import { TransportOpenTab, setFilters, matches, duplicate } from '@/common/trans
},
keymap() {
const result = {
'ctrl+shift+T': this.reopenLastClosedTab,
'ctrl+shift+t': this.reopenLastClosedTab,
'ctrl+tab': this.nextTab,
'ctrl+shift+tab': this.previousTab,
'alt+1': this.handleAltNumberKeyPress,
@@ -416,8 +414,9 @@ import { TransportOpenTab, setFilters, matches, duplicate } from '@/common/trans
}
if(this.$config.isMac) {
result['shift+meta+['] = this.previousTab
result['shift+meta+]'] = this.nextTab
result['meta+shift+t'] = this.reopenLastClosedTab
result['shift+meta+['] = this.previousTab
result['shift+meta+]'] = this.nextTab
}
return result
@@ -596,25 +595,26 @@ import { TransportOpenTab, setFilters, matches, duplicate } from '@/common/trans
handleCreateTab() {
this.createQuery()
},
createQuery(optionalText, queryTitle?) {
async createQuery(optionalText, queryTitle?) {
// const text = optionalText ? optionalText : ""
console.log("Creating tab")
let qNum = 0
let tabName = "New Query"
do {
qNum = qNum + 1
tabName = `Query #${qNum}`
} while (this.tabItems.filter((t) => t.title === tabName).length > 0);
if (queryTitle) {
tabName = queryTitle
} else {
do {
qNum = qNum + 1
tabName = `Query #${qNum}`
} while (this.tabItems.filter((t) => t.title === tabName).length > 0);
}
const result = {} as TransportOpenTab;
result.tabType = 'query'
result.title = tabName,
result.unsavedChanges = false
result.unsavedQueryText = optionalText
this.addTab(result)
const result = {} as TransportOpenTab;
result.tabType = 'query'
result.title = tabName,
result.unsavedChanges = false
result.unsavedQueryText = optionalText
await this.addTab(result)
},
async loadTableCreate(table) {
let method = null
@@ -877,9 +877,9 @@ import { TransportOpenTab, setFilters, matches, duplicate } from '@/common/trans
openTableProperties({ table }) {
const t = {} as TransportOpenTab;
t.tabType = 'table-properties';
t.tableName = table.name
t.schemaName = table.schema
t.title = table.name
t.tableName = table.name ?? table.tableName
t.schemaName = table.schema ?? table.schemaName
t.title = table.name ?? table.tableName
const existing = this.tabItems.find((tab) => matches(tab, t))
if (existing) return this.$store.dispatch('tabs/setActive', existing)
this.addTab(t)
@@ -887,9 +887,9 @@ import { TransportOpenTab, setFilters, matches, duplicate } from '@/common/trans
async openTable({ table, filters, openDetailView }) {
let tab = {} as TransportOpenTab;
tab.tabType = 'table';
tab.title = table.name
tab.tableName = table.name
tab.schemaName = table.schema
tab.title = table.name ?? table.tableName
tab.tableName = table.name ?? table.tableName
tab.schemaName = table.schema ?? table.schemaName
tab.entityType = table.entityType
tab = setFilters(tab, filters)
tab.titleScope = "all"
@@ -1040,7 +1040,7 @@ import { TransportOpenTab, setFilters, matches, duplicate } from '@/common/trans
},
createQueryFromItem(item) {
this.createQuery(item.text)
this.createQuery(item.text ?? item.unsavedQueryText, item.title ?? null)
}
},
beforeDestroy() {

View File

@@ -63,7 +63,7 @@
class="shortcut"
v-else
>
<span>Ctrl</span><span>Shift</span><span>N</span>
<span>Ctrl</span><span>⇧</span><span>N</span>
</div>
</div>
<div class="shortcut-item">
@@ -81,6 +81,21 @@
<span>Ctrl</span><span>T</span>
</div>
</div>
<div class="shortcut-item">
<div>Reopen Closed Tabs</div>
<div
class="shortcut"
v-if="$config.isMac"
>
<span>⌘</span><span>⇧</span><span>T</span>
</div>
<div
class="shortcut"
v-else
>
<span>Ctrl</span><span>⇧</span><span>T</span>
</div>
</div>
<div class="shortcut-item">
<div>Close Tab</div>
<div

View File

@@ -26,6 +26,40 @@
>
<li>No Results</li>
</ul>
<ul
class="results"
v-if="!results.length && !searchTerm && historyResults.length"
>
<li
class="result-item"
v-for="(blob, idx) in historyResults"
:key="idx"
:class="{selected: idx === selectedItem}"
@click.prevent="handleHistoryClick($event, blob)"
>
<table-icon
v-if="blob.tabType === 'table' || blob.tabDetails?.tabType === 'table'"
:table="blob"
/>
<i
class="material-icons item-icon query"
v-else-if="blob.tabType === 'table-properties' || blob.tabDetails?.tabType === 'table-properties'"
>construction</i>
<i
class="material-icons item-icon connection"
v-else-if="blob.tabType === 'connection' || blob.tabDetails?.tabType === 'connection'"
>power</i>
<i
class="material-icons item-icon database"
v-else-if="blob.tabType === 'database' || blob.tabDetails?.tabType === 'database'"
>storage</i>
<i
class="material-icons item-icon database"
v-else
>code</i>
<span v-html="highlightHistory(blob)" />
</li>
</ul>
<div
class="results empty"
v-if="!results.length && !searchTerm"
@@ -112,6 +146,7 @@ export default Vue.extend({
document.addEventListener('mousedown', this.maybeHide)
this.$nextTick(() => {
this.$refs.searchBox.focus()
this.getTabHistory()
})
},
beforeDestroy() {
@@ -122,7 +157,8 @@ export default Vue.extend({
active: false,
searchTerm: null,
results: [],
selectedItem: 0
selectedItem: 0,
historyResults: []
}
},
watch: {
@@ -148,8 +184,11 @@ export default Vue.extend({
},
computed: {
...mapState(['usedConfig']),
...mapState('search', ['searchIndex']),
...mapGetters({ database: 'search/database'}),
...mapState(['tables']),
...mapState('tabs', { 'tabs': 'tabs' }),
elements() {
if (this.$refs.menu) {
return Array.from(this.$refs.menu.getElementsByTagName("*"))
@@ -179,6 +218,10 @@ export default Vue.extend({
}
},
methods: {
async getTabHistory() {
const results = await Vue.prototype.$util.send('appdb/tabhistory/get', { workspaceId: this.usedConfig.workspaceId, connectionId: this.usedConfig.id });
this.historyResults = results
},
highlight(blob) {
const dangerous = blob.title
const text = escapeHtml(dangerous || "unknown item")
@@ -187,7 +230,16 @@ export default Vue.extend({
return result
},
highlightHistory(blob) {
const dangerous = blob.title
let historyText = [escapeHtml(dangerous || 'unknown item')]
if (blob.deletedAt) {
historyText.push('recently closed')
}
return historyText.join(' - ')
},
openSearch() {
this.$nextTick(() => {
this.$refs.searchBox.focus()
@@ -240,6 +292,15 @@ export default Vue.extend({
}
if (!persistSearch) this.closeSearch()
},
async handleHistoryClick(_event: MouseEvent, result: any) {
this.closeSearch()
if (result.deletedAt) {
result.deletedAt = null
await this.$store.dispatch('tabs/add', { item: result } )
}
return await this.$store.dispatch('tabs/setActive', result)
},
handleClick(event: MouseEvent, result: any) {
if (event.ctrlKey) {
this.submitAlt(result)
@@ -248,13 +309,22 @@ export default Vue.extend({
}
},
enter() {
const result = this.results[this.selectedItem]
this.submit(result)
let result = this.results[this.selectedItem]
if (!this.results.length && !this.searchTerm && this.historyResults.length) {
result = this.historyResults[this.selectedItem]
this.handleHistoryClick(_, result)
} else {
this.submit(result)
}
},
metaEnter() {
const result = this.results[this.selectedItem]
this.submitAlt(result)
let result = this.results[this.selectedItem]
if (!this.results.length && !this.searchTerm && this.historyResults.length) {
result = this.historyResults[this.selectedItem]
this.handleHistoryClick(_, result)
} else {
this.submitAlt(result)
}
},
persistentSearchEnter(){
const cursorPosition = this.$refs.searchBox.selectionStart

View File

@@ -32,7 +32,7 @@ class State {
connectionAbortController: AbortController = null;
// enums
enumsInitialized: boolean = false;
enumsInitialized = false;
private enumWatcher: FSWatcher = null;

View File

@@ -0,0 +1,19 @@
import { OpenTab } from "@/common/appdb/models/OpenTab";
import { TransportOpenTab } from "@/common/transport/TransportOpenTab";
interface ConnectionIds {
connectionId: number,
workspaceId: number
}
export const TabHistoryHandlers = {
'appdb/tabhistory/get': async (connectionIds, limit = 10): Promise<TransportOpenTab[]> => {
return await OpenTab.getHistory(connectionIds, limit)
},
'appdb/tabhistory/getLastDeletedTab': async (connectionIds): Promise<TransportOpenTab> => {
return await OpenTab.getClosedHistory(connectionIds)
},
'appdb/tabhistory/clearDeletedTabs': async (connectionIds: ConnectionIds, xDays = 7): Promise<void> => {
return await OpenTab.clearOldDeletedTabs(connectionIds, xDays)
}
}

View File

@@ -0,0 +1,12 @@
export default {
name: '20241116-tab-history-updates',
async run(runner) {
const queries = [
`ALTER TABLE tabs ADD COLUMN lastActive datetime`,
`ALTER TABLE tabs ADD COLUMN deletedAt datetime`
]
for (let index = 0; index < queries.length; index++) {
await runner.query(queries[index])
}
}
}

View File

@@ -0,0 +1,12 @@
export default {
name: '20250211-add-tab-history-index',
async run(runner) {
const queries = [
`CREATE INDEX idx_tabs_deleted_at_desc ON tabs(deletedAt DESC)`,
`CREATE INDEX idx_tabs_last_active_desc ON tabs(lastActive DESC)`
]
for (let index = 0; index < queries.length; index++) {
await runner.query(queries[index])
}
}
}

View File

@@ -48,8 +48,10 @@ import lastUsedWorkspace from './20240923_user_settings_default_workspace'
import userSettingKeymap from './20241017_add_user_setting_keymap'
import missingUserSettings from './20241017_add_missing_user_settings'
import useBeta from './20241009_add_beta_toggle'
import tabHistoryUpdates from './20241116_tab_history_updates'
import deleteDuplicateConnections from './20241115_delete_duplicate_connections'
import addNewUrlField from './20250128_add_new_url_field'
import tabHistoryIndex from './20250211_add_tab_history_index'
import fixOracleData from './20250225_oracle_default_connection_method'
import ultimate from './ultimate/index'
@@ -77,7 +79,11 @@ const realMigrations = [
firebirdConnection, exportPath, UserSettingsWindowPosition,
demoSetup, minimalMode, tokenCache, libsqlOptions, nameTokenCache, lastUsedWorkspace,
maxAllowedAppRelease, userSettingKeymap, missingUserSettings,
useBeta, deleteDuplicateConnections, addNewUrlField,
useBeta,
deleteDuplicateConnections,
tabHistoryUpdates,
addNewUrlField,
tabHistoryIndex,
fixOracleData,
]

View File

@@ -451,6 +451,7 @@ const store = new Vuex.Store<State>({
await context.dispatch('data/usedconnections/recordUsed', config)
context.dispatch('updateWindowTitle', config)
await Vue.prototype.$util.send('appdb/tabhistory/clearDeletedTabs', { workspaceId: context.state.usedConfig.workspaceId, connectionId: context.state.usedConfig.id })
} else {
throw "No username provided"
}

View File

@@ -63,7 +63,12 @@ export const TabModule: Module<State, RootState> = {
state.lastClosedTabs.push(...tabs.map((tab) => duplicate(tab)))
},
setActive(state, tab?: TransportOpenTab) {
const tabs = state.tabs.map(t => {
t.active = t.id === tab.id
return t
})
state.active = tab
state.tabs = tabs
},
},
actions: {
@@ -78,7 +83,7 @@ export const TabModule: Module<State, RootState> = {
workspaceId: context.rootState.workspaceId
}
}
});
})
context.commit('set', tabs || [])
if (tabs?.length) {
const active = tabs.find((t) => t.active) || tabs[0]
@@ -87,25 +92,28 @@ export const TabModule: Module<State, RootState> = {
}
},
async unload(context) {
await Vue.prototype.$util.send('appdb/tabs/remove', { obj: context.state.tabs })
context.commit('remove', context.state.tabs)
context.commit('setActive', null)
},
async reopenLastClosedTab(context) {
// Walk through array in reverse to check if it belongs to the current connection
for (let i = context.state.lastClosedTabs.length - 1; i >= 0; i--) {
const tab = context.state.lastClosedTabs[i]
// Does this tab belong to the current connection?
if (tab.connectionId === context.rootState.usedConfig?.id && tab.workspaceId === context.rootState.workspaceId) {
const { usedConfig, workspaceId } = context.rootState
try {
const tab = await Vue.prototype.$util.send('appdb/tabhistory/getLastDeletedTab', { workspaceId: workspaceId, connectionId: usedConfig.id });
if (tab) {
tab.deletedAt = null
await context.dispatch('add', { item: tab })
await context.dispatch('setActive', tab)
break
}
}catch (err) {
console.error(err)
}
},
async add(context, options: { item: TransportOpenTab, endOfPosition?: boolean }) {
const { usedConfig } = context.rootState
let { item, endOfPosition } = options
const { endOfPosition } = options
let { item } = options
if (endOfPosition) {
item.position = (context.getters.sortedTabs.reverse()[0]?.position || 0) + 1
}
@@ -113,7 +121,9 @@ export const TabModule: Module<State, RootState> = {
log.info("saving tab", item)
item.workspaceId = context.rootState.workspaceId
item.connectionId = usedConfig.id
item = await Vue.prototype.$util.send('appdb/tabs/save', { obj: item });
item.deletedAt = null
item.active = true
item = await Vue.prototype.$util.send('appdb/tabs/save', { obj: item })
}
context.commit('add', item)
return item;
@@ -122,22 +132,27 @@ export const TabModule: Module<State, RootState> = {
items.forEach((p, idx) => p.position = idx)
const { usedConfig } = context.rootState
context.commit('set', items)
if (usedConfig?.id) await Vue.prototype.$util.send('appdb/tabs/save', { obj: items });
if (usedConfig?.id) await Vue.prototype.$util.send('appdb/tabs/save', { obj: items })
},
async remove(context, rawItems: TransportOpenTab | TransportOpenTab[]) {
const items = _.isArray(rawItems) ? rawItems : [rawItems]
items.forEach((tab) => context.commit('remove', tab))
items.forEach((tab) => {
tab.deletedAt = new Date()
tab.position = 99
return context.commit('remove', tab)
})
const { usedConfig } = context.rootState
if (usedConfig?.id) {
await Vue.prototype.$util.send('appdb/tabs/remove', { obj: items });
await Vue.prototype.$util.send('appdb/tabs/save', { obj: items })
}
},
async save(context, rawTabs: TransportOpenTab[] | TransportOpenTab) {
try {
if (rawTabs == null) return
const tabs = _.isArray(rawTabs) ? rawTabs : [rawTabs]
const { usedConfig } = context.rootState
if (usedConfig?.id) {
await Vue.prototype.$util.send('appdb/tabs/save', { obj: tabs });
await Vue.prototype.$util.send('appdb/tabs/save', { obj: tabs })
}
} catch (ex) {
console.error("tab/save", ex)
@@ -149,7 +164,6 @@ export const TabModule: Module<State, RootState> = {
} catch (ex) {
console.error("tab/saveAll", ex)
}
},
async setActive(context, tab: TransportOpenTab) {
const oldActive = context.state.active
@@ -158,8 +172,10 @@ export const TabModule: Module<State, RootState> = {
oldActive.active = false
}
tab.active = true
await context.dispatch('save', [tab, oldActive].filter((x) => !!x))
tab.lastActive = new Date()
tab.deletedAt = null
await context.dispatch('save', [tab, oldActive].filter((x) => !!x))
}
}