mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 10:59:53 +00:00
feat: navigate to previously active tab when closing current tab (#6624)
When closing a tab, the UI now returns to the most recently active tab instead of always going to the first/next tab. This matches standard browser tab behavior and prevents accidental edits in the wrong workflow. Implementation uses a lazy-cleanup history array (max 32 entries) that tracks tab activations and skips closed tabs when finding the previous tab to switch to. Fixes #6599 https://github.com/user-attachments/assets/0bb87969-fd01-4e6b-96e8-c0f741f23ff8 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6624-feat-navigate-to-previously-active-tab-when-closing-current-tab-2a36d73d365081f5be95db51ff7a03f6) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -215,9 +215,15 @@ export const useWorkflowService = () => {
|
||||
if (workflowStore.openWorkflows.length === 1) {
|
||||
await loadDefaultWorkflow()
|
||||
}
|
||||
// If this is the active workflow, load the next workflow
|
||||
// If this is the active workflow, load the most recent workflow from history
|
||||
if (workflowStore.isActive(workflow)) {
|
||||
await loadNextOpenedWorkflow()
|
||||
const mostRecentWorkflow = workflowStore.getMostRecentWorkflow()
|
||||
if (mostRecentWorkflow) {
|
||||
await openWorkflow(mostRecentWorkflow)
|
||||
} else {
|
||||
// Fallback to next workflow if no history
|
||||
await loadNextOpenedWorkflow()
|
||||
}
|
||||
}
|
||||
|
||||
await workflowStore.closeWorkflow(workflow)
|
||||
|
||||
@@ -158,6 +158,7 @@ interface WorkflowStore {
|
||||
isActive: (workflow: ComfyWorkflow) => boolean
|
||||
openWorkflows: ComfyWorkflow[]
|
||||
openedWorkflowIndexShift: (shift: number) => ComfyWorkflow | null
|
||||
getMostRecentWorkflow: () => ComfyWorkflow | null
|
||||
openWorkflow: (workflow: ComfyWorkflow) => Promise<LoadedComfyWorkflow>
|
||||
openWorkflowsInBackground: (paths: {
|
||||
left?: string[]
|
||||
@@ -201,6 +202,14 @@ interface WorkflowStore {
|
||||
}
|
||||
|
||||
export const useWorkflowStore = defineStore('workflow', () => {
|
||||
/**
|
||||
* History of tab activations. Most recent at the end.
|
||||
* Tracks the order in which tabs were activated to support "go to previous" behavior.
|
||||
* Lazily cleaned on access.
|
||||
*/
|
||||
const tabActivationHistory = ref<string[]>([])
|
||||
const MAX_HISTORY_SIZE = 32
|
||||
|
||||
/**
|
||||
* Detach the workflow from the store. lightweight helper function.
|
||||
* @param workflow The workflow to detach.
|
||||
@@ -308,6 +317,18 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
const loadedWorkflow = await workflow.load()
|
||||
activeWorkflow.value = loadedWorkflow
|
||||
comfyApp.canvas.bg_tint = loadedWorkflow.tintCanvasBg
|
||||
|
||||
// Track activation in history (move to end if already present)
|
||||
const historyIndex = tabActivationHistory.value.indexOf(workflow.path)
|
||||
if (historyIndex !== -1) {
|
||||
tabActivationHistory.value.splice(historyIndex, 1)
|
||||
}
|
||||
tabActivationHistory.value.push(workflow.path)
|
||||
// Trim history if too large
|
||||
if (tabActivationHistory.value.length > MAX_HISTORY_SIZE) {
|
||||
tabActivationHistory.value.shift()
|
||||
}
|
||||
|
||||
return loadedWorkflow
|
||||
}
|
||||
|
||||
@@ -405,6 +426,39 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recently active workflow from history (excluding current).
|
||||
* Lazily cleans invalid paths from history.
|
||||
* @returns The most recent valid workflow or null if none found.
|
||||
*/
|
||||
const getMostRecentWorkflow = (): ComfyWorkflow | null => {
|
||||
const currentPath = activeWorkflow.value?.path
|
||||
const validPaths: string[] = []
|
||||
|
||||
// Scan backwards through history
|
||||
for (let i = tabActivationHistory.value.length - 1; i >= 0; i--) {
|
||||
const path = tabActivationHistory.value[i]
|
||||
|
||||
// Skip current workflow
|
||||
if (path === currentPath) continue
|
||||
|
||||
// Check if workflow is still open
|
||||
if (openWorkflowPathSet.value.has(path)) {
|
||||
validPaths.unshift(path)
|
||||
const workflow = workflowLookup.value[path]
|
||||
if (workflow) {
|
||||
// Lazy cleanup: keep only valid paths
|
||||
tabActivationHistory.value = validPaths
|
||||
return workflow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup: no valid workflows found, clear history
|
||||
tabActivationHistory.value = []
|
||||
return null
|
||||
}
|
||||
|
||||
const persistedWorkflows = computed(() =>
|
||||
Array.from(workflows.value).filter(
|
||||
(workflow) =>
|
||||
@@ -714,6 +768,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
||||
isActive,
|
||||
openWorkflows,
|
||||
openedWorkflowIndexShift,
|
||||
getMostRecentWorkflow,
|
||||
openWorkflow,
|
||||
openWorkflowsInBackground,
|
||||
isOpen,
|
||||
|
||||
@@ -3,9 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type {
|
||||
ComfyWorkflow,
|
||||
LoadedComfyWorkflow
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import {
|
||||
type LoadedComfyWorkflow,
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
@@ -723,4 +725,93 @@ describe('useWorkflowStore', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Tab Activation History', () => {
|
||||
let workflowA: ComfyWorkflow
|
||||
let workflowB: ComfyWorkflow
|
||||
let workflowC: ComfyWorkflow
|
||||
|
||||
beforeEach(async () => {
|
||||
await syncRemoteWorkflows(['a.json', 'b.json', 'c.json'])
|
||||
workflowA = store.getWorkflowByPath('workflows/a.json')!
|
||||
workflowB = store.getWorkflowByPath('workflows/b.json')!
|
||||
workflowC = store.getWorkflowByPath('workflows/c.json')!
|
||||
vi.mocked(api.getUserData).mockResolvedValue({
|
||||
status: 200,
|
||||
text: () => Promise.resolve(defaultGraphJSON)
|
||||
} as Response)
|
||||
})
|
||||
|
||||
it('should return most recently active workflow', async () => {
|
||||
// Open workflows in order: A -> B -> C
|
||||
await store.openWorkflow(workflowA)
|
||||
await store.openWorkflow(workflowB)
|
||||
await store.openWorkflow(workflowC)
|
||||
|
||||
// C is current, B should be most recent
|
||||
const mostRecent = store.getMostRecentWorkflow()
|
||||
expect(mostRecent?.path).toBe(workflowB.path)
|
||||
})
|
||||
|
||||
it('should skip closed workflows (lazy cleanup)', async () => {
|
||||
// Open workflows: A -> B -> C
|
||||
await store.openWorkflow(workflowA)
|
||||
await store.openWorkflow(workflowB)
|
||||
await store.openWorkflow(workflowC)
|
||||
|
||||
// Close B (the most recent before C)
|
||||
await store.closeWorkflow(workflowB)
|
||||
|
||||
// C is current, B is closed, so A should be returned
|
||||
const mostRecent = store.getMostRecentWorkflow()
|
||||
expect(mostRecent?.path).toBe(workflowA.path)
|
||||
})
|
||||
|
||||
it('should return null when no valid history exists', async () => {
|
||||
// Open only one workflow
|
||||
await store.openWorkflow(workflowA)
|
||||
|
||||
// No previous workflows, should return null
|
||||
const mostRecent = store.getMostRecentWorkflow()
|
||||
expect(mostRecent).toBeNull()
|
||||
})
|
||||
|
||||
it('should track history when opening workflows', async () => {
|
||||
// Open A, then B, then A again
|
||||
await store.openWorkflow(workflowA)
|
||||
await store.openWorkflow(workflowB)
|
||||
await store.openWorkflow(workflowA)
|
||||
|
||||
// A is current, B should be most recent
|
||||
const mostRecent = store.getMostRecentWorkflow()
|
||||
expect(mostRecent?.path).toBe(workflowB.path)
|
||||
})
|
||||
|
||||
it('should handle workflow activated multiple times', async () => {
|
||||
// Open: A -> B -> A -> C
|
||||
await store.openWorkflow(workflowA)
|
||||
await store.openWorkflow(workflowB)
|
||||
await store.openWorkflow(workflowA)
|
||||
await store.openWorkflow(workflowC)
|
||||
|
||||
// C is current, A should be most recent (not B)
|
||||
const mostRecent = store.getMostRecentWorkflow()
|
||||
expect(mostRecent?.path).toBe(workflowA.path)
|
||||
})
|
||||
|
||||
it('should clean up history when all previous workflows are closed', async () => {
|
||||
// Open: A -> B -> C
|
||||
await store.openWorkflow(workflowA)
|
||||
await store.openWorkflow(workflowB)
|
||||
await store.openWorkflow(workflowC)
|
||||
|
||||
// Close A and B
|
||||
await store.closeWorkflow(workflowA)
|
||||
await store.closeWorkflow(workflowB)
|
||||
|
||||
// C is current, no valid history
|
||||
const mostRecent = store.getMostRecentWorkflow()
|
||||
expect(mostRecent).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user