mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-30 19:21:54 +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) {
|
if (workflowStore.openWorkflows.length === 1) {
|
||||||
await loadDefaultWorkflow()
|
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)) {
|
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)
|
await workflowStore.closeWorkflow(workflow)
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ interface WorkflowStore {
|
|||||||
isActive: (workflow: ComfyWorkflow) => boolean
|
isActive: (workflow: ComfyWorkflow) => boolean
|
||||||
openWorkflows: ComfyWorkflow[]
|
openWorkflows: ComfyWorkflow[]
|
||||||
openedWorkflowIndexShift: (shift: number) => ComfyWorkflow | null
|
openedWorkflowIndexShift: (shift: number) => ComfyWorkflow | null
|
||||||
|
getMostRecentWorkflow: () => ComfyWorkflow | null
|
||||||
openWorkflow: (workflow: ComfyWorkflow) => Promise<LoadedComfyWorkflow>
|
openWorkflow: (workflow: ComfyWorkflow) => Promise<LoadedComfyWorkflow>
|
||||||
openWorkflowsInBackground: (paths: {
|
openWorkflowsInBackground: (paths: {
|
||||||
left?: string[]
|
left?: string[]
|
||||||
@@ -201,6 +202,14 @@ interface WorkflowStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useWorkflowStore = defineStore('workflow', () => {
|
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.
|
* Detach the workflow from the store. lightweight helper function.
|
||||||
* @param workflow The workflow to detach.
|
* @param workflow The workflow to detach.
|
||||||
@@ -308,6 +317,18 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
|||||||
const loadedWorkflow = await workflow.load()
|
const loadedWorkflow = await workflow.load()
|
||||||
activeWorkflow.value = loadedWorkflow
|
activeWorkflow.value = loadedWorkflow
|
||||||
comfyApp.canvas.bg_tint = loadedWorkflow.tintCanvasBg
|
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
|
return loadedWorkflow
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,6 +426,39 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
|||||||
return null
|
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(() =>
|
const persistedWorkflows = computed(() =>
|
||||||
Array.from(workflows.value).filter(
|
Array.from(workflows.value).filter(
|
||||||
(workflow) =>
|
(workflow) =>
|
||||||
@@ -714,6 +768,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
|
|||||||
isActive,
|
isActive,
|
||||||
openWorkflows,
|
openWorkflows,
|
||||||
openedWorkflowIndexShift,
|
openedWorkflowIndexShift,
|
||||||
|
getMostRecentWorkflow,
|
||||||
openWorkflow,
|
openWorkflow,
|
||||||
openWorkflowsInBackground,
|
openWorkflowsInBackground,
|
||||||
isOpen,
|
isOpen,
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
|
|
||||||
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
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 {
|
import {
|
||||||
type LoadedComfyWorkflow,
|
|
||||||
useWorkflowBookmarkStore,
|
useWorkflowBookmarkStore,
|
||||||
useWorkflowStore
|
useWorkflowStore
|
||||||
} from '@/platform/workflow/management/stores/workflowStore'
|
} 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