-
Notifications
You must be signed in to change notification settings - Fork 729
feat(chat): dedupe workspace labels, split sidebar #2142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
6e5c278
feat(chat): add workspace label disambiguation util
zhangmo8 29f89c1
refactor(chat): split sidebar into composables
zhangmo8 dd088d9
test(renderer): update scroll write allowlist
zhangmo8 a0ea81c
fix(renderer): harden sidebar composables against races
zhangmo8 d6bb505
fix(renderer): address second-round review on sidebar PR
zhangmo8 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
186 changes: 186 additions & 0 deletions
186
src/renderer/src/composables/sidebar/useProjectGroupReorder.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| import { computed, nextTick, ref, type MaybeRefOrGetter, type Ref, toValue } from 'vue' | ||
| import { tryOnScopeDispose } from '@vueuse/core' | ||
| import type { useProjectStore } from '@/stores/ui/project' | ||
| import type { SessionGroup, useSessionStore } from '@/stores/ui/session' | ||
| import type { SidebarWorkspaceGroup } from './useSidebarWorkspaceGroups' | ||
| import { restoreSessionListScrollTop } from './useSessionListAutoFill' | ||
|
|
||
| export type ProjectGroupMoveTarget = 'top' | 'up' | 'down' | 'bottom' | ||
|
|
||
| interface UseProjectGroupReorderOptions { | ||
| sessionStore: ReturnType<typeof useSessionStore> | ||
| projectStore: ReturnType<typeof useProjectStore> | ||
| sessionListRef: Ref<HTMLElement | null> | ||
| collapsed: MaybeRefOrGetter<boolean> | ||
| normalizedSearchQuery: MaybeRefOrGetter<string> | ||
| pinFlightSessionId: MaybeRefOrGetter<string | null> | ||
| workspaceGroups: MaybeRefOrGetter<SidebarWorkspaceGroup[]> | ||
| /** Shared drag flag owned by the component so sibling composables can pause on it. */ | ||
| isProjectGroupDragging: Ref<boolean> | ||
| isActiveProjectDirectoryGroup: (group: SessionGroup) => boolean | ||
| getGroupIdentifier: (group: SessionGroup) => string | ||
| getWorkspacePath: (group: SessionGroup) => string | ||
| ensureSessionListFilled: () => Promise<void> | ||
| onDragStart?: () => void | ||
| } | ||
|
|
||
| /** | ||
| * Drag-and-drop plus menu-based reordering for active workspace groups. Reorders are | ||
| * committed to the project store as a full environment order, preserving hidden | ||
| * environments in their current slots. | ||
| */ | ||
| export function useProjectGroupReorder(options: UseProjectGroupReorderOptions) { | ||
| const { sessionStore, projectStore, sessionListRef, isProjectGroupDragging } = options | ||
|
|
||
| const projectGroupDragScrollTop = ref<number | null>(null) | ||
|
|
||
| const projectReorderableGroups = computed(() => | ||
| toValue(options.workspaceGroups).filter(options.isActiveProjectDirectoryGroup) | ||
| ) | ||
| const canReorderProjectGroups = computed( | ||
| () => | ||
| !toValue(options.collapsed) && | ||
| sessionStore.groupMode === 'project' && | ||
| toValue(options.normalizedSearchQuery).length === 0 && | ||
| !toValue(options.pinFlightSessionId) && | ||
| projectStore.snapshotReady && | ||
| sessionStore.hasLoadedInitialPage && | ||
| !sessionStore.loading && | ||
| projectReorderableGroups.value.length > 1 | ||
| ) | ||
|
|
||
| const isProjectGroupReorderTarget = (group: SessionGroup) => | ||
| options.isActiveProjectDirectoryGroup(group) | ||
|
|
||
| const getCurrentProjectOrderPaths = () => { | ||
| const environmentPaths = projectStore.environments.map((environment) => environment.path) | ||
| return environmentPaths.length > 0 | ||
| ? environmentPaths | ||
| : projectReorderableGroups.value.map(options.getWorkspacePath) | ||
| } | ||
|
|
||
| const commitVisibleProjectGroupOrder = async (nextVisiblePaths: string[]) => { | ||
| const currentOrder = getCurrentProjectOrderPaths() | ||
| const previousVisiblePaths = projectReorderableGroups.value.map(options.getWorkspacePath) | ||
| const previousVisiblePathSet = new Set(previousVisiblePaths) | ||
| const nextVisiblePathSet = new Set(nextVisiblePaths) | ||
| const isPermutationOfVisiblePaths = | ||
| nextVisiblePaths.length === previousVisiblePaths.length && | ||
| previousVisiblePathSet.size === previousVisiblePaths.length && | ||
| nextVisiblePathSet.size === nextVisiblePaths.length && | ||
| nextVisiblePaths.every((path) => previousVisiblePathSet.has(path)) | ||
| if (!isPermutationOfVisiblePaths) { | ||
| console.warn('[WindowSideBar] Skipping project group reorder: visible group mismatch') | ||
| return | ||
| } | ||
| const nextOrder = [...currentOrder] | ||
| let nextVisibleIndex = 0 | ||
|
|
||
| for (let index = 0; index < nextOrder.length; index += 1) { | ||
| if (!previousVisiblePathSet.has(nextOrder[index])) { | ||
| continue | ||
| } | ||
|
|
||
| const nextPath = nextVisiblePaths[nextVisibleIndex] | ||
| if (nextPath) { | ||
| nextOrder[index] = nextPath | ||
| } | ||
| nextVisibleIndex += 1 | ||
| } | ||
|
|
||
| for (const path of nextVisiblePaths) { | ||
| if (!nextOrder.includes(path)) { | ||
| nextOrder.push(path) | ||
| } | ||
| } | ||
|
|
||
| await projectStore.reorderEnvironments(nextOrder) | ||
| } | ||
|
|
||
| const handleProjectGroupModelUpdate = (nextGroups: SessionGroup[]) => { | ||
| if (!canReorderProjectGroups.value) { | ||
| return | ||
| } | ||
|
|
||
| const nextVisiblePaths = nextGroups | ||
| .filter(options.isActiveProjectDirectoryGroup) | ||
| .map(options.getWorkspacePath) | ||
| void commitVisibleProjectGroupOrder(nextVisiblePaths).catch((error) => { | ||
| console.warn('[WindowSideBar] Failed to reorder project groups:', error) | ||
| }) | ||
| } | ||
|
|
||
| const canMoveProjectGroup = (group: SessionGroup, delta: -1 | 1) => { | ||
| if (!canReorderProjectGroups.value || !isProjectGroupReorderTarget(group)) { | ||
| return false | ||
| } | ||
|
|
||
| const groups = projectReorderableGroups.value | ||
| const index = groups.findIndex( | ||
| (candidate) => options.getGroupIdentifier(candidate) === options.getGroupIdentifier(group) | ||
| ) | ||
| if (index < 0) { | ||
| return false | ||
| } | ||
|
|
||
| return delta < 0 ? index > 0 : index < groups.length - 1 | ||
| } | ||
|
|
||
| const handleMoveProjectGroup = (group: SessionGroup, target: ProjectGroupMoveTarget) => { | ||
| if (!canReorderProjectGroups.value || !isProjectGroupReorderTarget(group)) { | ||
| return | ||
| } | ||
|
|
||
| const paths = projectReorderableGroups.value.map(options.getWorkspacePath) | ||
| const currentIndex = paths.indexOf(options.getWorkspacePath(group)) | ||
| if (currentIndex < 0) { | ||
| return | ||
| } | ||
|
|
||
| const [path] = paths.splice(currentIndex, 1) | ||
| const nextIndex = | ||
| target === 'top' | ||
| ? 0 | ||
| : target === 'bottom' | ||
| ? paths.length | ||
| : target === 'up' | ||
| ? Math.max(0, currentIndex - 1) | ||
| : Math.min(paths.length, currentIndex + 1) | ||
|
|
||
| paths.splice(nextIndex, 0, path) | ||
| void commitVisibleProjectGroupOrder(paths).catch((error) => { | ||
| console.warn('[WindowSideBar] Failed to move project group:', error) | ||
| }) | ||
| } | ||
|
|
||
| const handleProjectGroupDragStart = () => { | ||
| isProjectGroupDragging.value = true | ||
| projectGroupDragScrollTop.value = sessionListRef.value?.scrollTop ?? null | ||
| options.onDragStart?.() | ||
| } | ||
|
|
||
| const handleProjectGroupDragEnd = () => { | ||
| void nextTick(() => { | ||
| restoreSessionListScrollTop(sessionListRef.value, projectGroupDragScrollTop.value) | ||
| projectGroupDragScrollTop.value = null | ||
| isProjectGroupDragging.value = false | ||
| void options.ensureSessionListFilled() | ||
| }) | ||
| } | ||
|
|
||
| tryOnScopeDispose(() => { | ||
| isProjectGroupDragging.value = false | ||
| projectGroupDragScrollTop.value = null | ||
| }) | ||
|
|
||
| return { | ||
| projectReorderableGroups, | ||
| canReorderProjectGroups, | ||
| isProjectGroupReorderTarget, | ||
| handleProjectGroupModelUpdate, | ||
| canMoveProjectGroup, | ||
| handleMoveProjectGroup, | ||
| handleProjectGroupDragStart, | ||
| handleProjectGroupDragEnd | ||
| } | ||
| } | ||
169 changes: 169 additions & 0 deletions
169
src/renderer/src/composables/sidebar/useSessionListAutoFill.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,169 @@ | ||
| import { nextTick, watch, type MaybeRefOrGetter, type Ref, toValue } from 'vue' | ||
| import { tryOnMounted, tryOnScopeDispose, useResizeObserver } from '@vueuse/core' | ||
| import type { useSessionStore } from '@/stores/ui/session' | ||
|
|
||
| export const restoreSessionListScrollTop = ( | ||
| listElement: HTMLElement | null, | ||
| scrollTop: number | null | ||
| ) => { | ||
| if (scrollTop === null || !listElement) { | ||
| return | ||
| } | ||
|
|
||
| listElement.scrollTop = scrollTop | ||
| } | ||
|
|
||
| interface UseSessionListAutoFillOptions { | ||
| sessionStore: ReturnType<typeof useSessionStore> | ||
| sessionListRef: Ref<HTMLElement | null> | ||
| collapsed: MaybeRefOrGetter<boolean> | ||
| canAutoFill: MaybeRefOrGetter<boolean> | ||
| /** While true (e.g. during a group drag) scrolling and auto-fill are paused. */ | ||
| suspended: MaybeRefOrGetter<boolean> | ||
| /** Extra reactive sources that should re-trigger the fill check when they change. */ | ||
| fillCheckSources: MaybeRefOrGetter<unknown>[] | ||
| } | ||
|
|
||
| /** | ||
| * Keeps the session list paginated: loads the next page near the scroll bottom, and when | ||
| * the first pages are too short to produce a scrollbar keeps loading until the viewport | ||
| * is filled (issue #1762). | ||
| */ | ||
| export function useSessionListAutoFill(options: UseSessionListAutoFillOptions) { | ||
| const { sessionStore, sessionListRef } = options | ||
|
|
||
| let scrollFrame: number | null = null | ||
| let fillFrame: number | null = null | ||
| let isFillingSessionList = false | ||
|
|
||
| const performSessionListScrollCheck = () => { | ||
| const listElement = sessionListRef.value | ||
| if ( | ||
| !listElement || | ||
| toValue(options.suspended) || | ||
| sessionStore.loadingMore || | ||
| !sessionStore.hasMore | ||
| ) { | ||
| return | ||
| } | ||
|
|
||
| const distanceToBottom = | ||
| listElement.scrollHeight - listElement.scrollTop - listElement.clientHeight | ||
|
|
||
| if (distanceToBottom <= 96) { | ||
| void sessionStore.loadNextPage() | ||
| } | ||
| } | ||
|
|
||
| const handleSessionListScroll = () => { | ||
| if (scrollFrame !== null) { | ||
| return | ||
| } | ||
|
|
||
| scrollFrame = window.requestAnimationFrame(() => { | ||
| scrollFrame = null | ||
| performSessionListScrollCheck() | ||
| }) | ||
| } | ||
|
|
||
| const ensureSessionListFilled = async () => { | ||
| if ( | ||
| isFillingSessionList || | ||
| toValue(options.suspended) || | ||
| toValue(options.collapsed) || | ||
| !toValue(options.canAutoFill) | ||
| ) { | ||
| return | ||
| } | ||
| isFillingSessionList = true | ||
| try { | ||
| // 轮数上限兜底,避免异常情况下(如 cursor 不推进)陷入死循环。 | ||
| const MAX_FILL_ROUNDS = 50 | ||
| for (let round = 0; round < MAX_FILL_ROUNDS; round += 1) { | ||
| await nextTick() | ||
| const listElement = sessionListRef.value | ||
| if ( | ||
| !listElement || | ||
| toValue(options.suspended) || | ||
| toValue(options.collapsed) || | ||
| !toValue(options.canAutoFill) || | ||
| !sessionStore.hasMore || | ||
| sessionStore.loadingMore || | ||
| sessionStore.loading | ||
| ) { | ||
| return | ||
| } | ||
| // 内容高度已超过容器(存在可滚动空间),交还给滚动事件处理后续分页。 | ||
| if (listElement.scrollHeight > listElement.clientHeight + 1) { | ||
| return | ||
| } | ||
| const beforeCount = sessionStore.sessions.length | ||
| const beforeHasMore = sessionStore.hasMore | ||
| await sessionStore.loadNextPage() | ||
| if ( | ||
| beforeHasMore === sessionStore.hasMore && | ||
| sessionStore.hasMore && | ||
| sessionStore.sessions.length <= beforeCount | ||
| ) { | ||
| return | ||
| } | ||
| } | ||
| } finally { | ||
| isFillingSessionList = false | ||
| } | ||
| } | ||
|
|
||
| const scheduleSessionListFillCheck = () => { | ||
| if (fillFrame !== null) { | ||
| return | ||
| } | ||
|
|
||
| fillFrame = window.requestAnimationFrame(() => { | ||
| fillFrame = null | ||
| void ensureSessionListFilled() | ||
| }) | ||
| } | ||
|
|
||
| // 会话列表内容或容器高度变化后,若视口仍未填满则继续加载,保证「滚动加载更多」 | ||
| // 在首屏内容过少时也能启动(issue #1762)。搜索仅过滤已加载会话,仍要求所有 | ||
| // 分组展开,避免因为筛选或隐藏行扫完剩余分页。 | ||
| watch( | ||
| [ | ||
| () => sessionStore.sessions.length, | ||
| () => sessionStore.hasMore, | ||
| () => sessionStore.loading, | ||
| () => sessionStore.groupMode, | ||
| () => toValue(options.collapsed), | ||
| ...options.fillCheckSources.map((source) => () => toValue(source)) | ||
| ], | ||
| () => { | ||
| scheduleSessionListFillCheck() | ||
| }, | ||
| { immediate: true } | ||
| ) | ||
|
|
||
| useResizeObserver(sessionListRef, () => { | ||
| scheduleSessionListFillCheck() | ||
| }) | ||
|
|
||
| tryOnMounted(() => { | ||
| scheduleSessionListFillCheck() | ||
| }) | ||
|
|
||
| tryOnScopeDispose(() => { | ||
| if (scrollFrame !== null) { | ||
| window.cancelAnimationFrame(scrollFrame) | ||
| scrollFrame = null | ||
| } | ||
|
|
||
| if (fillFrame !== null) { | ||
| window.cancelAnimationFrame(fillFrame) | ||
| fillFrame = null | ||
| } | ||
| }) | ||
|
|
||
| return { | ||
| handleSessionListScroll, | ||
| ensureSessionListFilled | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.