mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-07-10 01:38:37 +00:00
fix: scope spaces by user and disable legacy remote control
This commit is contained in:
parent
1fa65f3cf3
commit
c0f0ccd3d4
8 changed files with 616 additions and 56 deletions
|
|
@ -324,25 +324,40 @@ class RemoteControlService:
|
|||
return RemoteControlService._get_owned_space(user_id, history.space_id, db)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _ensure_remote_control_supported_space(space: Space) -> None:
|
||||
if space.source_type == SpaceSourceType.LEGACY:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "REMOTE_CONTROL_LEGACY_SPACE_UNSUPPORTED",
|
||||
"message": "Legacy Spaces do not support remote control.",
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _session_space(session: RemoteControlSession, user_id: int, db: Session) -> Space:
|
||||
if session.space_id:
|
||||
return RemoteControlService._get_owned_space(user_id, session.space_id, db)
|
||||
space = RemoteControlService._get_owned_space(user_id, session.space_id, db)
|
||||
RemoteControlService._ensure_remote_control_supported_space(space)
|
||||
return space
|
||||
project_id, _, _, _ = RemoteControlService._effective_target(session)
|
||||
if project_id:
|
||||
space = RemoteControlService._space_for_project(user_id, project_id, db)
|
||||
if space:
|
||||
RemoteControlService._ensure_remote_control_supported_space(space)
|
||||
session.space_id = space.id
|
||||
session.space_name_snapshot = space.name
|
||||
db.add(session)
|
||||
db.flush()
|
||||
return space
|
||||
space = SpaceService.ensure_legacy_space(user_id, db)
|
||||
session.space_id = space.id
|
||||
session.space_name_snapshot = space.name
|
||||
db.add(session)
|
||||
db.flush()
|
||||
return space
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "REMOTE_CONTROL_SPACE_REQUIRED",
|
||||
"message": "Remote control requires a non-legacy Space.",
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_folder_space(space: Space) -> None:
|
||||
|
|
@ -515,9 +530,22 @@ class RemoteControlService:
|
|||
elif target_project_id:
|
||||
space = RemoteControlService._space_for_project(user_id, target_project_id, db)
|
||||
if not space:
|
||||
space = SpaceService.ensure_legacy_space(user_id, db)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "REMOTE_CONTROL_SPACE_REQUIRED",
|
||||
"message": "Remote control requires a non-legacy Space.",
|
||||
},
|
||||
)
|
||||
else:
|
||||
space = SpaceService.ensure_legacy_space(user_id, db)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"code": "REMOTE_CONTROL_SPACE_REQUIRED",
|
||||
"message": "Remote control requires a non-legacy Space.",
|
||||
},
|
||||
)
|
||||
RemoteControlService._ensure_remote_control_supported_space(space)
|
||||
if target_project_id:
|
||||
project = RemoteControlService._get_owned_project(user_id, target_project_id, db)
|
||||
if project and project.space_id != space.id:
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from app.model.project import (
|
|||
Project,
|
||||
ProjectIn,
|
||||
ProjectOut,
|
||||
ProjectStatus,
|
||||
ProjectUpdate,
|
||||
ProjectWorkdirMode,
|
||||
)
|
||||
|
|
@ -506,12 +507,31 @@ class SpaceService:
|
|||
@staticmethod
|
||||
def list_spaces(user_id: int | str, s: Session) -> list[SpaceOut]:
|
||||
canonical_user_id = SpaceService.canonical_user_id(user_id)
|
||||
SpaceService.ensure_legacy_space(canonical_user_id, s)
|
||||
spaces = s.exec(
|
||||
select(Space)
|
||||
.where(Space.user_id == canonical_user_id)
|
||||
.order_by(Space.updated_at.desc())
|
||||
).all()
|
||||
legacy_space_ids = [
|
||||
space.id
|
||||
for space in spaces
|
||||
if space.source_type == SpaceSourceType.LEGACY
|
||||
]
|
||||
if legacy_space_ids:
|
||||
legacy_space_ids_with_projects = set(
|
||||
s.exec(
|
||||
select(Project.space_id)
|
||||
.where(Project.user_id == canonical_user_id)
|
||||
.where(Project.space_id.in_(legacy_space_ids))
|
||||
.where(Project.status != ProjectStatus.ARCHIVED)
|
||||
).all()
|
||||
)
|
||||
spaces = [
|
||||
space
|
||||
for space in spaces
|
||||
if space.source_type != SpaceSourceType.LEGACY
|
||||
or space.id in legacy_space_ids_with_projects
|
||||
]
|
||||
return [SpaceOut.from_model(space) for space in spaces]
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
112
server/tests/test_space_legacy_behavior.py
Normal file
112
server/tests/test_space_legacy_behavior.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.domains.remote_control.service.remote_control_service import RemoteControlService
|
||||
from app.domains.space.service.space_service import SpaceService
|
||||
from app.model.space.space import SpaceSourceType
|
||||
|
||||
|
||||
class _ExecResult:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
class _ListSpacesSession:
|
||||
def __init__(self, rows=None, project_space_ids=None):
|
||||
self._rows = rows or []
|
||||
self._project_space_ids = project_space_ids or []
|
||||
self._exec_count = 0
|
||||
|
||||
def exec(self, _statement):
|
||||
self._exec_count += 1
|
||||
if self._exec_count > 1:
|
||||
return _ExecResult(self._project_space_ids)
|
||||
return _ExecResult(self._rows)
|
||||
|
||||
|
||||
def _space(
|
||||
space_id: str,
|
||||
source_type: str,
|
||||
*,
|
||||
user_id: str = "user_new",
|
||||
name: str = "Untitled Space",
|
||||
):
|
||||
return SimpleNamespace(
|
||||
id=space_id,
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
description=None,
|
||||
source_type=source_type,
|
||||
root_path=None,
|
||||
root_fingerprint=None,
|
||||
status="active",
|
||||
schema_version=1,
|
||||
metadata_json={"legacy": True} if source_type == SpaceSourceType.LEGACY else None,
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
|
||||
def test_list_spaces_does_not_create_legacy_for_empty_user(monkeypatch):
|
||||
def fail_ensure(*_args, **_kwargs):
|
||||
raise AssertionError("list_spaces must not create Legacy Space")
|
||||
|
||||
monkeypatch.setattr(SpaceService, "ensure_legacy_space", fail_ensure)
|
||||
|
||||
assert SpaceService.list_spaces("user_new", _ListSpacesSession()) == []
|
||||
|
||||
|
||||
def test_list_spaces_hides_empty_legacy_space():
|
||||
spaces = [
|
||||
_space("legacy_user_new", SpaceSourceType.LEGACY, name="Legacy Space"),
|
||||
_space("space_new", SpaceSourceType.BLANK),
|
||||
]
|
||||
|
||||
result = SpaceService.list_spaces("user_new", _ListSpacesSession(spaces))
|
||||
|
||||
assert [space.id for space in result] == ["space_new"]
|
||||
|
||||
|
||||
def test_list_spaces_keeps_legacy_space_with_active_projects():
|
||||
legacy_space = _space(
|
||||
"legacy_user_old",
|
||||
SpaceSourceType.LEGACY,
|
||||
user_id="user_old",
|
||||
name="Legacy Space",
|
||||
)
|
||||
|
||||
result = SpaceService.list_spaces(
|
||||
"user_old",
|
||||
_ListSpacesSession([legacy_space], project_space_ids=["legacy_user_old"]),
|
||||
)
|
||||
|
||||
assert [space.id for space in result] == ["legacy_user_old"]
|
||||
|
||||
|
||||
def test_remote_control_rejects_legacy_space():
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
RemoteControlService._ensure_remote_control_supported_space(
|
||||
SimpleNamespace(source_type=SpaceSourceType.LEGACY)
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.detail["code"] == "REMOTE_CONTROL_LEGACY_SPACE_UNSUPPORTED"
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
revokeRemoteControlSession,
|
||||
waitForRemoteControlBridgeConnected,
|
||||
} from '@/lib/remoteControl';
|
||||
import { canUseRemoteControlInSpace } from '@/lib/spaceLabel';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getConnectionConfig } from '@/store/connectionStore';
|
||||
import { useProjectRuntimeStore } from '@/store/projectRuntimeStore';
|
||||
|
|
@ -176,6 +177,7 @@ interface ChannelRowProps {
|
|||
icon?: React.ReactNode;
|
||||
imgSrc?: string;
|
||||
disabled?: boolean;
|
||||
disabledLabel?: string;
|
||||
comingSoon?: boolean;
|
||||
hasActiveSessions?: boolean;
|
||||
loading?: boolean;
|
||||
|
|
@ -187,12 +189,14 @@ function ChannelRow({
|
|||
icon,
|
||||
imgSrc,
|
||||
disabled,
|
||||
disabledLabel,
|
||||
comingSoon,
|
||||
hasActiveSessions,
|
||||
loading,
|
||||
onStart,
|
||||
}: ChannelRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const isDisabled = Boolean(disabled);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -200,9 +204,9 @@ function ChannelRow({
|
|||
'group/row flex h-[44px] w-full cursor-default select-none items-center gap-3 rounded-xl',
|
||||
'border border-transparent bg-ds-bg-neutral-default-default px-3',
|
||||
'transition-colors duration-150',
|
||||
!disabled && 'hover:border-ds-border-neutral-subtle-default',
|
||||
!isDisabled && 'hover:border-ds-border-neutral-subtle-default',
|
||||
hasActiveSessions && 'border-ds-border-neutral-subtle-default',
|
||||
disabled && 'cursor-not-allowed opacity-50'
|
||||
isDisabled && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<div className="flex shrink-0 items-center">
|
||||
|
|
@ -228,13 +232,19 @@ function ChannelRow({
|
|||
</span>
|
||||
)}
|
||||
|
||||
{!disabled && !comingSoon && hasActiveSessions && (
|
||||
{isDisabled && !comingSoon && disabledLabel && (
|
||||
<span className="shrink-0 rounded-full bg-ds-bg-neutral-muted-default px-2 py-0.5 text-label-xs text-ds-text-neutral-muted-default">
|
||||
{disabledLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isDisabled && !comingSoon && hasActiveSessions && (
|
||||
<span className="shrink-0 rounded-full bg-ds-bg-success-subtle-default px-2 py-0.5 text-label-xs text-ds-text-success-strong-default">
|
||||
{t('layout.dispatch-connected', { defaultValue: 'Connected' })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!disabled && !comingSoon && !hasActiveSessions && (
|
||||
{!isDisabled && !comingSoon && !hasActiveSessions && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
|
|
@ -332,6 +342,8 @@ export function WorkspaceDispatch() {
|
|||
s.activeSpaceId ? s.spaces[s.activeSpaceId] : null
|
||||
);
|
||||
const activeProjectId = useProjectRuntimeStore((s) => s.activeProjectId);
|
||||
const remoteControlUnsupportedForSpace =
|
||||
activeSpace !== null && !canUseRemoteControlInSpace(activeSpace);
|
||||
|
||||
const activeSessions = useTriggerStore((s) => s.activeRemoteControlSessions);
|
||||
const logs = useTriggerStore((s) => s.remoteControlLogs);
|
||||
|
|
@ -416,6 +428,10 @@ export function WorkspaceDispatch() {
|
|||
toast.error('Open a Space before starting remote control.');
|
||||
return;
|
||||
}
|
||||
if (!activeSpace || !canUseRemoteControlInSpace(activeSpace)) {
|
||||
toast.error('Legacy Spaces do not support remote control.');
|
||||
return;
|
||||
}
|
||||
|
||||
setRemoteControlLoading(true);
|
||||
try {
|
||||
|
|
@ -478,6 +494,7 @@ export function WorkspaceDispatch() {
|
|||
}
|
||||
}, [
|
||||
activeProjectId,
|
||||
activeSpace,
|
||||
activeSpace?.name,
|
||||
activeSpaceId,
|
||||
addRemoteControlSession,
|
||||
|
|
@ -599,6 +616,10 @@ export function WorkspaceDispatch() {
|
|||
/>
|
||||
}
|
||||
hasActiveSessions={hasActiveRemoteControl}
|
||||
disabled={remoteControlUnsupportedForSpace && !hasActiveRemoteControl}
|
||||
disabledLabel={t('layout.dispatch-unavailable', {
|
||||
defaultValue: 'Unavailable',
|
||||
})}
|
||||
loading={remoteControlLoading}
|
||||
onStart={() => void handleCreateRemoteControl()}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -65,6 +65,14 @@ export function canCreateProjectInSpace(
|
|||
return !isLegacySpace(space);
|
||||
}
|
||||
|
||||
/** Remote control requires a regular Space target; legacy migration buckets are unsupported. */
|
||||
export function canUseRemoteControlInSpace(
|
||||
space: Space | null | undefined
|
||||
): boolean {
|
||||
if (!space) return false;
|
||||
return !isLegacySpace(space);
|
||||
}
|
||||
|
||||
/** Folder-backed and scratch Spaces with a local workspace root behave locally. */
|
||||
export function isLocalWorkspaceSpace(
|
||||
space: Space | null | undefined
|
||||
|
|
|
|||
|
|
@ -131,11 +131,13 @@ const getRandomDefaultModel = (): CloudModelType => {
|
|||
};
|
||||
|
||||
const hydrateSpacesForUser = (userId: number | string | null | undefined) => {
|
||||
if (!useSpaceStore?.getState) return;
|
||||
if (userId === null || userId === undefined || userId === '') {
|
||||
useSpaceStore.getState().resetForUser(null);
|
||||
useSpaceStore.getState().ensureLegacySpace();
|
||||
return;
|
||||
}
|
||||
useSpaceStore.getState().ensureLegacySpace(userId);
|
||||
useSpaceStore.getState().resetForUser(userId);
|
||||
void useSpaceStore.getState().hydrateFromServer(userId);
|
||||
};
|
||||
|
||||
|
|
@ -173,6 +175,11 @@ const authStore = create<AuthState>()(
|
|||
|
||||
// auth related methods
|
||||
setAuth: ({ token, username, email, user_id }) => {
|
||||
const previousUserId = get().user_id;
|
||||
if (previousUserId != null && previousUserId !== user_id) {
|
||||
void clearAllCachedProjects(previousUserId);
|
||||
}
|
||||
useSpaceStore.getState().resetForUser(user_id);
|
||||
set({ token, username, email, user_id });
|
||||
hydrateSpacesForUser(user_id);
|
||||
},
|
||||
|
|
@ -193,6 +200,7 @@ const authStore = create<AuthState>()(
|
|||
initState: 'carousel',
|
||||
localProxyValue: null,
|
||||
});
|
||||
useSpaceStore.getState().resetForUser(null);
|
||||
},
|
||||
|
||||
// set related methods
|
||||
|
|
@ -433,12 +441,9 @@ export const useAuthStore = authStore;
|
|||
export const getAuthStore = () => authStore.getState();
|
||||
|
||||
queueMicrotask(() => {
|
||||
const { token, user_id } = authStore.getState();
|
||||
if (token) {
|
||||
hydrateSpacesForUser(user_id);
|
||||
} else {
|
||||
useSpaceStore.getState().ensureLegacySpace(user_id);
|
||||
}
|
||||
if (!useSpaceStore?.getState) return;
|
||||
const { user_id } = authStore.getState();
|
||||
hydrateSpacesForUser(user_id);
|
||||
});
|
||||
|
||||
// constant definition
|
||||
|
|
|
|||
196
src/store/spaceStore.test.ts
Normal file
196
src/store/spaceStore.test.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
SPACE_SCHEMA_VERSION,
|
||||
type Space,
|
||||
type SpaceSourceType,
|
||||
useSpaceStore,
|
||||
} from './spaceStore';
|
||||
|
||||
const authStoreMock = vi.hoisted(() => ({
|
||||
state: {
|
||||
user_id: 2,
|
||||
email: 'new@example.com',
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/store/authStore', () => ({
|
||||
getAuthStore: () => authStoreMock.state,
|
||||
}));
|
||||
|
||||
vi.mock('@/api/http', () => ({
|
||||
proxyFetchGet: vi.fn().mockResolvedValue({ projects: [] }),
|
||||
}));
|
||||
|
||||
vi.mock('@/service/spaceApi', () => ({
|
||||
proxyCreateSpace: vi.fn(),
|
||||
proxyEnsureLegacySpace: vi.fn(),
|
||||
proxyFetchSpaceProjects: vi.fn(),
|
||||
proxyFetchSpaces: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/service/workspaceApi', () => ({
|
||||
reconcileWorkspaceBindings: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const makeSpace = (
|
||||
id: string,
|
||||
name: string,
|
||||
sourceType: SpaceSourceType,
|
||||
userId = '2',
|
||||
metadata?: Space['metadata']
|
||||
): Space => ({
|
||||
id,
|
||||
name,
|
||||
userId,
|
||||
sourceType,
|
||||
rootPath: null,
|
||||
rootFingerprint: null,
|
||||
status: 'active',
|
||||
schemaVersion: SPACE_SCHEMA_VERSION,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
metadata,
|
||||
});
|
||||
|
||||
describe('spaceStore user scoping', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const spaceApi = await import('@/service/spaceApi');
|
||||
vi.mocked(spaceApi.proxyFetchSpaces).mockResolvedValue([]);
|
||||
vi.mocked(spaceApi.proxyFetchSpaceProjects).mockResolvedValue([]);
|
||||
vi.mocked(spaceApi.proxyCreateSpace).mockResolvedValue(
|
||||
makeSpace('space_created', 'Untitled Space', 'blank')
|
||||
);
|
||||
vi.mocked(spaceApi.proxyEnsureLegacySpace).mockResolvedValue(
|
||||
makeSpace('legacy_2', 'Legacy Space', 'legacy', '2', { legacy: true })
|
||||
);
|
||||
authStoreMock.state = {
|
||||
email: 'new@example.com',
|
||||
user_id: 2,
|
||||
};
|
||||
useSpaceStore.setState({
|
||||
activeSpaceId: 'space_old_blank',
|
||||
spaces: {
|
||||
space_old_blank: {
|
||||
id: 'space_old_blank',
|
||||
name: 'Untitled Space',
|
||||
userId: '1',
|
||||
sourceType: 'blank',
|
||||
status: 'active',
|
||||
schemaVersion: SPACE_SCHEMA_VERSION,
|
||||
createdAt: 1,
|
||||
updatedAt: 3,
|
||||
},
|
||||
legacy_1: {
|
||||
id: 'legacy_1',
|
||||
name: 'Legacy Space',
|
||||
userId: '1',
|
||||
sourceType: 'legacy',
|
||||
status: 'active',
|
||||
schemaVersion: SPACE_SCHEMA_VERSION,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
metadata: { legacy: true },
|
||||
},
|
||||
space_new_blank: {
|
||||
id: 'space_new_blank',
|
||||
name: 'Untitled Space',
|
||||
userId: '2',
|
||||
sourceType: 'blank',
|
||||
status: 'active',
|
||||
schemaVersion: SPACE_SCHEMA_VERSION,
|
||||
createdAt: 1,
|
||||
updatedAt: 4,
|
||||
},
|
||||
},
|
||||
lastVisitedProjectBySpace: {
|
||||
space_old_blank: 'project_old',
|
||||
space_new_blank: 'project_new',
|
||||
},
|
||||
projectsBySpaceId: {
|
||||
space_old_blank: {
|
||||
project_old: {
|
||||
id: 'project_old',
|
||||
userId: '1',
|
||||
spaceId: 'space_old_blank',
|
||||
name: 'Old project',
|
||||
status: 'active',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
space_new_blank: {
|
||||
project_new: {
|
||||
id: 'project_new',
|
||||
userId: '2',
|
||||
spaceId: 'space_new_blank',
|
||||
name: 'New project',
|
||||
status: 'active',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
projectIdIndex: {
|
||||
project_old: 'space_old_blank',
|
||||
project_new: 'space_new_blank',
|
||||
},
|
||||
projectsSyncedAt: {
|
||||
space_old_blank: 100,
|
||||
legacy_1: 100,
|
||||
space_new_blank: 200,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('removes spaces and project metadata from the previous signed-in user', () => {
|
||||
useSpaceStore.getState().resetForUser(2);
|
||||
|
||||
const state = useSpaceStore.getState();
|
||||
expect(Object.keys(state.spaces)).toEqual(['space_new_blank']);
|
||||
expect(state.activeSpaceId).toBe('space_new_blank');
|
||||
expect(Object.keys(state.projectsBySpaceId)).toEqual(['space_new_blank']);
|
||||
expect(state.projectIdIndex).toEqual({ project_new: 'space_new_blank' });
|
||||
expect(state.lastVisitedProjectBySpace).toEqual({
|
||||
space_new_blank: 'project_new',
|
||||
});
|
||||
expect(state.projectsSyncedAt).toEqual({ space_new_blank: 200 });
|
||||
});
|
||||
|
||||
it('hydrates new accounts with one blank space and hides empty legacy rows', async () => {
|
||||
const spaceApi = await import('@/service/spaceApi');
|
||||
vi.mocked(spaceApi.proxyFetchSpaces).mockResolvedValue([
|
||||
makeSpace('legacy_2', 'Legacy Space', 'legacy', '2', { legacy: true }),
|
||||
]);
|
||||
vi.mocked(spaceApi.proxyCreateSpace).mockResolvedValue(
|
||||
makeSpace('space_new_blank', 'Untitled Space', 'blank')
|
||||
);
|
||||
|
||||
await useSpaceStore.getState().hydrateFromServer(2);
|
||||
|
||||
const state = useSpaceStore.getState();
|
||||
expect(spaceApi.proxyFetchSpaceProjects).toHaveBeenCalledWith('legacy_2');
|
||||
expect(spaceApi.proxyCreateSpace).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Untitled Space',
|
||||
source_type: 'blank',
|
||||
})
|
||||
);
|
||||
expect(Object.keys(state.spaces)).toEqual(['space_new_blank']);
|
||||
expect(state.activeSpaceId).toBe('space_new_blank');
|
||||
});
|
||||
});
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
type SessionNavLeadPresentation,
|
||||
} from '@/lib/sessionNavLead';
|
||||
import {
|
||||
isLegacySpace,
|
||||
isLocalWorkspaceSpace,
|
||||
isPlaceholderProjectName,
|
||||
isPlaceholderSpaceNameStatic,
|
||||
|
|
@ -39,6 +40,8 @@ export const DEFAULT_LOCAL_USER_ID = 'local';
|
|||
const PROJECT_SYNC_TTL_MS = 5 * 60 * 1000;
|
||||
const PROJECT_PLACEHOLDER_RESYNC_MS = 10 * 1000;
|
||||
const PROJECT_DISPLAY_NAME_MAX = 80;
|
||||
const INITIAL_BLANK_SPACE_NAME = 'Untitled Space';
|
||||
const INITIAL_BLANK_SPACE_CREATED_FROM = 'initial_hydrate';
|
||||
|
||||
export type SpaceSourceType = 'blank' | 'folder' | 'legacy';
|
||||
export type SpaceStatus = 'active' | 'disconnected' | 'archived';
|
||||
|
|
@ -102,6 +105,7 @@ interface SpaceStore {
|
|||
projectsBySpaceId: Record<string, Record<string, SpaceProjectMeta>>;
|
||||
projectIdIndex: Record<string, string>;
|
||||
projectsSyncedAt: Record<string, number>;
|
||||
resetForUser: (userId?: string | number | null) => void;
|
||||
ensureLegacySpace: (userId?: string | number | null) => string;
|
||||
hydrateFromServer: (userId?: string | number | null) => Promise<void>;
|
||||
syncProjectsFromServer: (spaceId: string) => Promise<void>;
|
||||
|
|
@ -160,6 +164,12 @@ const canonicalUserId = (userId?: string | number | null) =>
|
|||
? DEFAULT_LOCAL_USER_ID
|
||||
: String(userId);
|
||||
|
||||
const spaceBelongsToUser = (space: Space, userId?: string | number | null) => {
|
||||
const ownerId = canonicalUserId(userId);
|
||||
if (!space.userId) return ownerId === DEFAULT_LOCAL_USER_ID;
|
||||
return String(space.userId) === ownerId;
|
||||
};
|
||||
|
||||
export const legacySpaceIdForUser = (userId?: string | number | null) =>
|
||||
`legacy_${canonicalUserId(userId)}`;
|
||||
|
||||
|
|
@ -233,6 +243,7 @@ const hasVisibleProjectsForSpace = (
|
|||
) => getVisibleProjectMetasForSpace(projectsBySpaceId, spaceId).length > 0;
|
||||
|
||||
const DISPOSABLE_BLANK_SPACE_CREATED_FROM = new Set([
|
||||
INITIAL_BLANK_SPACE_CREATED_FROM,
|
||||
'home_hub_toolbar',
|
||||
'top_bar',
|
||||
'project_sidebar_space_selector',
|
||||
|
|
@ -328,6 +339,40 @@ const pruneAutoCreatedProjectMetas = (
|
|||
};
|
||||
};
|
||||
|
||||
const activeSpacesByRecentUpdate = (spaces: Record<string, Space>) =>
|
||||
Object.values(spaces)
|
||||
.filter((space) => space.status === 'active')
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
const pickHydratedActiveSpaceId = (
|
||||
spaces: Record<string, Space>,
|
||||
currentActiveSpaceId: string | null | undefined,
|
||||
localLegacyId: string,
|
||||
preferredSpaceId?: string | null
|
||||
) => {
|
||||
if (preferredSpaceId && spaces[preferredSpaceId]?.status === 'active') {
|
||||
return preferredSpaceId;
|
||||
}
|
||||
|
||||
const currentActiveSpace = currentActiveSpaceId
|
||||
? spaces[currentActiveSpaceId]
|
||||
: null;
|
||||
if (
|
||||
currentActiveSpace &&
|
||||
currentActiveSpace.status === 'active' &&
|
||||
currentActiveSpace.id !== localLegacyId
|
||||
) {
|
||||
return currentActiveSpace.id;
|
||||
}
|
||||
|
||||
const activeSpaces = activeSpacesByRecentUpdate(spaces);
|
||||
return (
|
||||
activeSpaces.find((space) => !isLegacySpace(space))?.id ??
|
||||
activeSpaces[0]?.id ??
|
||||
null
|
||||
);
|
||||
};
|
||||
|
||||
const truncateProjectDisplayName = (name: string) =>
|
||||
name.length > PROJECT_DISPLAY_NAME_MAX
|
||||
? `${name.slice(0, PROJECT_DISPLAY_NAME_MAX - 3)}...`
|
||||
|
|
@ -429,6 +474,15 @@ const unbindBrainWorkspaceMirror = async (spaceId: string) => {
|
|||
await unbindWorkspaceFromBrain(spaceId, email, userId);
|
||||
};
|
||||
|
||||
const isHydrationStillCurrentForUser = async (ownerId: string) => {
|
||||
try {
|
||||
const { getAuthStore } = await import('@/store/authStore');
|
||||
return canonicalUserId(getAuthStore().user_id) === ownerId;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export const useSpaceStore = create<SpaceStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
|
|
@ -439,22 +493,138 @@ export const useSpaceStore = create<SpaceStore>()(
|
|||
projectIdIndex: {},
|
||||
projectsSyncedAt: {},
|
||||
|
||||
resetForUser: (userId) =>
|
||||
set((state) => {
|
||||
const nextSpaces: Record<string, Space> = {};
|
||||
for (const [spaceId, space] of Object.entries(state.spaces)) {
|
||||
if (spaceBelongsToUser(space, userId)) {
|
||||
nextSpaces[spaceId] = space;
|
||||
}
|
||||
}
|
||||
|
||||
const nextProjectsBySpaceId: Record<
|
||||
string,
|
||||
Record<string, SpaceProjectMeta>
|
||||
> = {};
|
||||
const nextProjectIdIndex: Record<string, string> = {};
|
||||
for (const spaceId of Object.keys(nextSpaces)) {
|
||||
const projects = state.projectsBySpaceId[spaceId];
|
||||
if (!projects) continue;
|
||||
nextProjectsBySpaceId[spaceId] = projects;
|
||||
for (const projectId of Object.keys(projects)) {
|
||||
nextProjectIdIndex[projectId] = spaceId;
|
||||
}
|
||||
}
|
||||
|
||||
const nextLastVisitedProjectBySpace: Record<string, string> = {};
|
||||
for (const [spaceId, projectId] of Object.entries(
|
||||
state.lastVisitedProjectBySpace
|
||||
)) {
|
||||
if (nextProjectsBySpaceId[spaceId]?.[projectId]) {
|
||||
nextLastVisitedProjectBySpace[spaceId] = projectId;
|
||||
}
|
||||
}
|
||||
|
||||
const nextProjectsSyncedAt: Record<string, number> = {};
|
||||
for (const spaceId of Object.keys(nextSpaces)) {
|
||||
const syncedAt = state.projectsSyncedAt[spaceId];
|
||||
if (syncedAt !== undefined) {
|
||||
nextProjectsSyncedAt[spaceId] = syncedAt;
|
||||
}
|
||||
}
|
||||
|
||||
const localLegacyId = legacySpaceIdForUser(DEFAULT_LOCAL_USER_ID);
|
||||
return {
|
||||
spaces: nextSpaces,
|
||||
activeSpaceId: pickHydratedActiveSpaceId(
|
||||
nextSpaces,
|
||||
state.activeSpaceId,
|
||||
localLegacyId
|
||||
),
|
||||
lastVisitedProjectBySpace: nextLastVisitedProjectBySpace,
|
||||
projectsBySpaceId: nextProjectsBySpaceId,
|
||||
projectIdIndex: nextProjectIdIndex,
|
||||
projectsSyncedAt: nextProjectsSyncedAt,
|
||||
};
|
||||
}),
|
||||
|
||||
hydrateFromServer: async (userId) => {
|
||||
try {
|
||||
const [{ proxyEnsureLegacySpace, proxyFetchSpaces }, projectModule] =
|
||||
await Promise.all([
|
||||
import('@/service/spaceApi'),
|
||||
import('./projectRuntimeStore'),
|
||||
]);
|
||||
const [legacySpace, serverSpaces] = await Promise.all([
|
||||
proxyEnsureLegacySpace(),
|
||||
proxyFetchSpaces(),
|
||||
const [
|
||||
{ proxyCreateSpace, proxyFetchSpaceProjects, proxyFetchSpaces },
|
||||
projectModule,
|
||||
] = await Promise.all([
|
||||
import('@/service/spaceApi'),
|
||||
import('./projectRuntimeStore'),
|
||||
]);
|
||||
const spaces = serverSpaces.some(
|
||||
(space) => space.id === legacySpace.id
|
||||
)
|
||||
? serverSpaces
|
||||
: [legacySpace, ...serverSpaces];
|
||||
const ownerId = canonicalUserId(userId);
|
||||
const serverSpaces = await proxyFetchSpaces();
|
||||
if (!(await isHydrationStillCurrentForUser(ownerId))) {
|
||||
return;
|
||||
}
|
||||
const ownedSpaces = serverSpaces.filter(
|
||||
(space) => !space.userId || String(space.userId) === ownerId
|
||||
);
|
||||
const activeOwnedSpaces = ownedSpaces.filter(
|
||||
(space) => space.status === 'active'
|
||||
);
|
||||
const hasActiveNonLegacySpace = activeOwnedSpaces.some(
|
||||
(space) => !isLegacySpace(space)
|
||||
);
|
||||
const activeLegacySpaces = activeOwnedSpaces.filter(isLegacySpace);
|
||||
const legacySpaceIdsWithProjects = new Set<string>();
|
||||
let initialBlankSpace: Space | null = null;
|
||||
|
||||
for (const legacySpace of activeLegacySpaces) {
|
||||
try {
|
||||
const legacyProjects = await proxyFetchSpaceProjects(
|
||||
legacySpace.id
|
||||
);
|
||||
if (
|
||||
legacyProjects.some((project) => project.status !== 'archived')
|
||||
) {
|
||||
legacySpaceIdsWithProjects.add(legacySpace.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[spaceStore] Failed to inspect legacy Space ${legacySpace.id}; preserving it as the active fallback:`,
|
||||
error
|
||||
);
|
||||
legacySpaceIdsWithProjects.add(legacySpace.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasActiveNonLegacySpace) {
|
||||
if (legacySpaceIdsWithProjects.size === 0) {
|
||||
try {
|
||||
initialBlankSpace = await proxyCreateSpace({
|
||||
name: INITIAL_BLANK_SPACE_NAME,
|
||||
source_type: 'blank',
|
||||
metadata: {
|
||||
createdFrom: INITIAL_BLANK_SPACE_CREATED_FROM,
|
||||
autoCreatedPlaceholder: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[spaceStore] Failed to create initial blank Space:',
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await isHydrationStillCurrentForUser(ownerId))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleOwnedSpaces = ownedSpaces.filter(
|
||||
(space) =>
|
||||
!isLegacySpace(space) || legacySpaceIdsWithProjects.has(space.id)
|
||||
);
|
||||
const spaces = initialBlankSpace
|
||||
? [initialBlankSpace, ...visibleOwnedSpaces]
|
||||
: visibleOwnedSpaces;
|
||||
void Promise.all([
|
||||
import('@/service/workspaceApi'),
|
||||
import('@/store/authStore'),
|
||||
|
|
@ -464,7 +634,10 @@ export const useSpaceStore = create<SpaceStore>()(
|
|||
const userId = authModule.getAuthStore().user_id;
|
||||
if (!email) return;
|
||||
const bindingSpaceIds = spaces
|
||||
.filter((space) => space.status !== 'archived')
|
||||
.filter(
|
||||
(space) =>
|
||||
space.status !== 'archived' && !isLegacySpace(space)
|
||||
)
|
||||
.map((space) => space.id);
|
||||
return workspaceModule
|
||||
.reconcileWorkspaceBindings(email, bindingSpaceIds, userId)
|
||||
|
|
@ -500,28 +673,24 @@ export const useSpaceStore = create<SpaceStore>()(
|
|||
);
|
||||
});
|
||||
const localLegacyId = legacySpaceIdForUser(DEFAULT_LOCAL_USER_ID);
|
||||
const serverLegacyId = legacySpace.id;
|
||||
const ownerId = canonicalUserId(userId ?? legacySpace.userId);
|
||||
const serverLegacySpace = spaces.find(isLegacySpace);
|
||||
const serverLegacyId =
|
||||
serverLegacySpace?.id ?? legacySpaceIdForUser(ownerId);
|
||||
const hasServerLegacySpace = Boolean(serverLegacySpace);
|
||||
|
||||
set((state) => {
|
||||
const nextSpaces: Record<string, Space> = {};
|
||||
for (const space of spaces) {
|
||||
if (!space.userId || String(space.userId) === ownerId) {
|
||||
nextSpaces[space.id] = space;
|
||||
}
|
||||
nextSpaces[space.id] = space;
|
||||
}
|
||||
if (!nextSpaces[serverLegacyId]) {
|
||||
nextSpaces[serverLegacyId] = legacySpace;
|
||||
}
|
||||
const shouldAdoptServerLegacy =
|
||||
!state.activeSpaceId ||
|
||||
state.activeSpaceId === localLegacyId ||
|
||||
!nextSpaces[state.activeSpaceId];
|
||||
return {
|
||||
spaces: nextSpaces,
|
||||
activeSpaceId: shouldAdoptServerLegacy
|
||||
? serverLegacyId
|
||||
: state.activeSpaceId,
|
||||
activeSpaceId: pickHydratedActiveSpaceId(
|
||||
nextSpaces,
|
||||
state.activeSpaceId,
|
||||
localLegacyId,
|
||||
initialBlankSpace?.id
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -543,11 +712,13 @@ export const useSpaceStore = create<SpaceStore>()(
|
|||
projectIdIndex: pruned.projectIdIndex,
|
||||
};
|
||||
});
|
||||
rehomeLegacyRuntimeProjects(
|
||||
projectStore,
|
||||
localLegacyId,
|
||||
serverLegacyId
|
||||
);
|
||||
if (hasServerLegacySpace) {
|
||||
rehomeLegacyRuntimeProjects(
|
||||
projectStore,
|
||||
localLegacyId,
|
||||
serverLegacyId
|
||||
);
|
||||
}
|
||||
|
||||
const activeSpaceId = get().activeSpaceId;
|
||||
if (activeSpaceId && get().shouldSyncProjects(activeSpaceId)) {
|
||||
|
|
@ -561,7 +732,6 @@ export const useSpaceStore = create<SpaceStore>()(
|
|||
'[spaceStore] Failed to hydrate spaces from server:',
|
||||
error
|
||||
);
|
||||
get().ensureLegacySpace(userId);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue