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
84 changes: 83 additions & 1 deletion src/crates/assembly/core/src/service/workspace/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ use std::sync::Arc;
use tokio::fs;
use tokio::sync::RwLock;

const MAX_WORKSPACE_NAME_CHARS: usize = 80;

/// Workspace service.
pub struct WorkspaceService {
manager: Arc<RwLock<WorkspaceManager>>,
Expand Down Expand Up @@ -103,6 +105,28 @@ struct AssistantWorkspaceDescriptor {
}

impl WorkspaceService {
fn normalize_workspace_name(name: String) -> BitFunResult<String> {
let name = name.trim();

if name.is_empty() {
return Err(BitFunError::service("Workspace name cannot be empty"));
}

if name.chars().any(char::is_control) {
return Err(BitFunError::service(
"Workspace name cannot contain control characters",
));
}

if name.chars().count() > MAX_WORKSPACE_NAME_CHARS {
return Err(BitFunError::service(format!(
"Workspace name cannot exceed {MAX_WORKSPACE_NAME_CHARS} characters"
)));
}

Ok(name.to_string())
}

fn collect_startup_restored_workspaces(manager: &WorkspaceManager) -> Vec<WorkspaceInfo> {
let mut targets = Vec::new();
let mut seen_workspace_ids = HashSet::new();
Expand Down Expand Up @@ -1093,6 +1117,11 @@ impl WorkspaceService {
related_paths,
} = updates;

let normalized_name = match name {
Some(name) => Some(Self::normalize_workspace_name(name)?),
None => None,
};

let existing_workspace = {
let manager = self.manager.read().await;
manager
Expand Down Expand Up @@ -1121,7 +1150,7 @@ impl WorkspaceService {
BitFunError::service(format!("Workspace not found: {}", workspace_id))
})?;

if let Some(name) = name {
if let Some(name) = normalized_name {
workspace.name = name;
}

Expand Down Expand Up @@ -2576,6 +2605,59 @@ mod tests {
);
}

#[tokio::test]
async fn update_workspace_info_normalizes_and_validates_project_name() {
let env = TestEnvironment::new();
let service = build_test_workspace_service(env.path_manager.clone()).await;
let workspace_root = env.create_workspace_dir("rename-project");
let workspace = service
.open_workspace(workspace_root)
.await
.expect("workspace should open");

let renamed = service
.update_workspace_info(
&workspace.id,
WorkspaceInfoUpdates {
name: Some(" Renamed project ".to_string()),
description: None,
tags: None,
related_paths: None,
},
)
.await
.expect("valid project name should be accepted");
assert_eq!(renamed.name, "Renamed project");
assert_eq!(
service
.get_workspace(&workspace.id)
.await
.expect("renamed workspace should remain available")
.name,
"Renamed project"
);

for invalid_name in [
" ".to_string(),
"Project\nName".to_string(),
"x".repeat(MAX_WORKSPACE_NAME_CHARS + 1),
] {
let error = service
.update_workspace_info(
&workspace.id,
WorkspaceInfoUpdates {
name: Some(invalid_name),
description: None,
tags: None,
related_paths: None,
},
)
.await
.expect_err("invalid project name should be rejected");
assert!(error.to_string().contains("Workspace name"));
}
}

#[test]
fn normalize_related_path_description_treats_blank_as_none() {
assert_eq!(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React, { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch, Bot, Link2, ListChecks, Loader2, Clock3, ShieldCheck } from 'lucide-react';
import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch, Bot, Link2, ListChecks, Loader2, Clock3, ShieldCheck, Pencil } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { DotMatrixArrowRightIcon } from './DotMatrixArrowRightIcon';
import { Button, ConfirmDialog, Modal, Tooltip } from '@/component-library';
import { Button, ConfirmDialog, InputDialog, Modal, Tooltip } from '@/component-library';
import { useI18n } from '@/infrastructure/i18n';
import { aiExperienceConfigService } from '@/infrastructure/config/services/AIExperienceConfigService';
import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext';
Expand Down Expand Up @@ -51,6 +51,9 @@
const WorkspaceSessionBatchModal = lazy(() => import('./WorkspaceSessionBatchModal'));
const ScheduledJobsModal = lazy(() => import('@/app/components/scheduled-jobs/ScheduledJobsModal'));

const MAX_WORKSPACE_NAME_CHARS = 80;
const WORKSPACE_NAME_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;

Check warning on line 55 in src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx

View workflow job for this annotation

GitHub Actions / Frontend Build

Unexpected control character(s) in regular expression: \x00, \x1f

interface WorkspaceItemProps {
workspace: WorkspaceInfo;
isActive: boolean;
Expand Down Expand Up @@ -85,6 +88,7 @@
closeWorkspaceById,
deleteAssistantWorkspace,
resetAssistantWorkspace,
renameWorkspace,
} = useWorkspaceContext();
const { switchLeftPanelTab } = useApp();
const openNavScene = useNavSceneStore(s => s.openNavScene);
Expand Down Expand Up @@ -120,6 +124,7 @@
const [searchIndexModalOpen, setSearchIndexModalOpen] = useState(false);
const [scheduledJobsModalOpen, setScheduledJobsModalOpen] = useState(false);
const [sessionBatchModalOpen, setSessionBatchModalOpen] = useState(false);
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [workspaceSearchEnabled, setWorkspaceSearchEnabled] = useState(
() => aiExperienceConfigService.getSettings().enable_workspace_search,
);
Expand Down Expand Up @@ -492,6 +497,44 @@
setProjectPermissionsDialogOpen(true);
}, []);

const handleRequestRename = useCallback(() => {
setMenuOpen(false);
setRenameDialogOpen(true);
}, []);

const validateWorkspaceName = useCallback((value: string): string | null => {
const normalizedName = value.trim();
if (!normalizedName) {
return t('nav.workspaces.renameDialog.validation.required');
}
if (WORKSPACE_NAME_CONTROL_CHARACTERS.test(normalizedName)) {
return t('nav.workspaces.renameDialog.validation.invalidCharacters');
}
if (Array.from(normalizedName).length > MAX_WORKSPACE_NAME_CHARS) {
return t('nav.workspaces.renameDialog.validation.tooLong', {
max: MAX_WORKSPACE_NAME_CHARS,
});
}
return null;
}, [t]);

const handleRenameWorkspace = useCallback(async (name: string) => {
const normalizedName = name.trim();
if (normalizedName === workspace.name) {
return;
}

try {
await renameWorkspace(workspace.id, normalizedName);
notificationService.success(t('nav.workspaces.renamed'), { duration: 2500 });
} catch (error) {
notificationService.error(
error instanceof Error ? error.message : t('nav.workspaces.renameFailed'),
{ duration: 4000 }
);
}
}, [renameWorkspace, t, workspace.id, workspace.name]);

const handleRequestDeleteAssistant = useCallback(() => {
setMenuOpen(false);
setDeleteDialogOpen(true);
Expand Down Expand Up @@ -1333,6 +1376,17 @@
<span className="bitfun-nav-panel__workspace-item-menu-label">{t('nav.scheduledJobs.open')}</span>
</button>
<div className="bitfun-nav-panel__workspace-item-menu-divider" />
<button
type="button"
className="bitfun-nav-panel__workspace-item-menu-item"
onClick={handleRequestRename}
data-testid="nav-workspace-menu-rename"
>
<Pencil size={13} />
<span className="bitfun-nav-panel__workspace-item-menu-label">
{t('nav.workspaces.actions.rename')}
</span>
</button>
{isLinkedWorktree ? (
<button
type="button"
Expand Down Expand Up @@ -1419,6 +1473,19 @@
/>
</div>

<InputDialog
isOpen={renameDialogOpen}
onClose={() => setRenameDialogOpen(false)}
onConfirm={(name) => { void handleRenameWorkspace(name); }}
title={t('nav.workspaces.renameDialog.title')}
description={t('nav.workspaces.renameDialog.description')}
placeholder={t('nav.workspaces.renameDialog.placeholder')}
defaultValue={workspace.name}
confirmText={t('actions.save')}
cancelText={t('actions.cancel')}
validator={validateWorkspaceName}
required={false}
/>
<BranchSelectModal
isOpen={worktreeModalOpen}
onClose={() => setWorktreeModalOpen(false)}
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/infrastructure/contexts/WorkspaceContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface WorkspaceContextValue extends WorkspaceState {
workspaceId: string,
relatedPaths: WorkspaceInfo['relatedPaths']
) => Promise<WorkspaceInfo>;
renameWorkspace: (workspaceId: string, name: string) => Promise<WorkspaceInfo>;
scanWorkspaceInfo: () => Promise<WorkspaceInfo | null>;
refreshRecentWorkspaces: () => Promise<void>;
removeWorkspaceFromRecent: (workspaceId: string) => Promise<void>;
Expand Down
13 changes: 13 additions & 0 deletions src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({ children }
workspaceId: string,
relatedPaths: WorkspaceInfo['relatedPaths']
) => workspaceManager.updateWorkspaceRelatedPaths(workspaceId, relatedPaths),
renameWorkspace: async (workspaceId: string, name: string) =>
workspaceManager.renameWorkspace(workspaceId, name),
scanWorkspaceInfo: async () => workspaceManager.scanWorkspaceInfo(),
refreshRecentWorkspaces: async () => workspaceManager.refreshRecentWorkspaces(),
removeWorkspaceFromRecent: async (workspaceId: string) =>
Expand Down Expand Up @@ -107,6 +109,8 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({ children }
workspaceId: string,
relatedPaths: WorkspaceInfo['relatedPaths']
) => workspaceManager.updateWorkspaceRelatedPaths(workspaceId, relatedPaths),
renameWorkspace: async (workspaceId: string, name: string) =>
workspaceManager.renameWorkspace(workspaceId, name),
scanWorkspaceInfo: async () => workspaceManager.scanWorkspaceInfo(),
refreshRecentWorkspaces: async () => workspaceManager.refreshRecentWorkspaces(),
removeWorkspaceFromRecent: async (workspaceId: string) =>
Expand Down Expand Up @@ -268,6 +272,13 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({ children }
return await workspaceManager.updateWorkspaceRelatedPaths(workspaceId, relatedPaths);
}, []);

const renameWorkspace = useCallback(async (
workspaceId: string,
name: string
): Promise<WorkspaceInfo> => {
return await workspaceManager.renameWorkspace(workspaceId, name);
}, []);

const refreshRecentWorkspaces = useCallback(async (): Promise<void> => {
return await workspaceManager.refreshRecentWorkspaces();
}, []);
Expand Down Expand Up @@ -300,6 +311,7 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({ children }
setActiveWorkspace,
reorderOpenedWorkspacesInSection,
updateWorkspaceRelatedPaths,
renameWorkspace,
scanWorkspaceInfo,
refreshRecentWorkspaces,
removeWorkspaceFromRecent,
Expand All @@ -319,6 +331,7 @@ export const WorkspaceProvider: React.FC<WorkspaceProviderProps> = ({ children }
setActiveWorkspace,
reorderOpenedWorkspacesInSection,
updateWorkspaceRelatedPaths,
renameWorkspace,
scanWorkspaceInfo,
refreshRecentWorkspaces,
removeWorkspaceFromRecent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const globalStateMocks = vi.hoisted(() => ({
getRecentWorkspaces: vi.fn(),
getOpenedWorkspaces: vi.fn(),
getCurrentWorkspace: vi.fn(),
updateWorkspaceInfo: vi.fn(),
}));

const listenMock = vi.hoisted(() => vi.fn());
Expand Down Expand Up @@ -52,6 +53,7 @@ function configureGlobalState(): void {
globalStateMocks.getRecentWorkspaces.mockResolvedValue([]);
globalStateMocks.getOpenedWorkspaces.mockResolvedValue([]);
globalStateMocks.getCurrentWorkspace.mockResolvedValue(null);
globalStateMocks.updateWorkspaceInfo.mockReset();
}

async function getFreshWorkspaceManager() {
Expand Down Expand Up @@ -314,3 +316,46 @@ describe('WorkspaceManager startup initialization', () => {
});
});
});

describe('WorkspaceManager project rename', () => {
beforeEach(() => {
vi.clearAllMocks();
configureGlobalState();
});

it('updates current, opened, and recent workspace records together', async () => {
const workspace = {
id: 'project-1',
name: 'Original project',
rootPath: 'D:/workspace/project-1',
workspaceKind: 'normal',
};
const renamedWorkspace = {
...workspace,
name: 'Renamed project',
};
globalStateMocks.initializeWorkspaceStartupState.mockResolvedValue({
cleanupRemovedCount: 0,
recentWorkspaces: [workspace],
openedWorkspaces: [workspace],
currentWorkspace: workspace,
legacyRemoteWorkspace: null,
});
globalStateMocks.updateWorkspaceInfo.mockResolvedValue(renamedWorkspace);
listenMock.mockResolvedValue(() => undefined);

const manager = await getFreshWorkspaceManager();
await manager.initialize();

await expect(manager.renameWorkspace('project-1', ' Renamed project '))
.resolves.toEqual(renamedWorkspace);

expect(globalStateMocks.updateWorkspaceInfo).toHaveBeenCalledWith('project-1', {
name: 'Renamed project',
});
const state = manager.getState();
expect(state.currentWorkspace?.name).toBe('Renamed project');
expect(state.openedWorkspaces.get('project-1')?.name).toBe('Renamed project');
expect(state.recentWorkspaces[0]?.name).toBe('Renamed project');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -1111,6 +1111,24 @@ class WorkspaceManager {
}
}

public async renameWorkspace(workspaceId: string, name: string): Promise<WorkspaceInfo> {
try {
this.setError(null);

const updatedWorkspace = await globalStateAPI.updateWorkspaceInfo(workspaceId, {
name: name.trim(),
});

this.applyWorkspaceRecordUpdate(updatedWorkspace);
return updatedWorkspace;
} catch (error) {
log.error('Failed to rename workspace', { workspaceId, error });
const errorMessage = error instanceof Error ? error.message : String(error);
this.updateState({ error: errorMessage }, { type: 'workspace:error', error: errorMessage });
throw error;
}
}

public async cleanupInvalidWorkspaces(): Promise<number> {
try {
const removedCount = await globalStateAPI.cleanupInvalidWorkspaces();
Expand Down
Loading
Loading