Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1,718 changes: 223 additions & 1,495 deletions src/renderer/src/components/WindowSideBar.vue

Large diffs are not rendered by default.

186 changes: 186 additions & 0 deletions src/renderer/src/composables/sidebar/useProjectGroupReorder.ts
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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 src/renderer/src/composables/sidebar/useSessionListAutoFill.ts
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
}
}
Loading