diff --git a/python/tests/test_workspace_author_preferences.py b/python/tests/test_workspace_author_preferences.py new file mode 100644 index 0000000..813f25b --- /dev/null +++ b/python/tests/test_workspace_author_preferences.py @@ -0,0 +1,69 @@ +import copy +import tempfile +import unittest +import uuid +from unittest.mock import patch +from workspace_author_preferences import selected_author, author_profiles, remember_author +import test_workspace_annotations as annotation_fixture + + +class MachineAuthorPreferenceTests(unittest.TestCase): + def setUp(self): + folder = tempfile.TemporaryDirectory() + self.addCleanup(folder.cleanup) + environment = patch.dict('os.environ', {'RIEKE_PREFERENCES_DIR': folder.name}) + environment.start() + self.addCleanup(environment.stop) + self.first = self.fixture() + self.second = self.fixture() + + def fixture(self): + case = annotation_fixture.SharedAnnotationTests() + case.setUp() + self.addCleanup(case.doCleanups) + return case + + def test_choose_once_reuses_the_same_author_uuid_in_another_project_without_rewriting_existing_tags(self): + old = self.first.store.create_profile('Previous scientist', 'local') + self.first.edit('epoch', self.first.first, ['old'], author=old['profile_uuid']) + before = copy.deepcopy(self.first.records.rows) + author = self.first.store.create_profile('Scientist', 'local') + response = self.first.client.post('/api/annotation-profiles/selected', json={'profile_uuid': author['profile_uuid']}, headers=self.first.headers) + self.assertEqual(response.status_code, 200, response.get_json()) + self.assertEqual(selected_author()['profile_uuid'], author['profile_uuid']) + listing = self.second.client.get('/api/annotation-profiles').get_json() + self.assertEqual(listing['selected_profile_uuid'], author['profile_uuid']) + self.assertIn(author['profile_uuid'], [profile['profile_uuid'] for profile in listing['profiles']]) + self.assertFalse(self.second.profiles.rows) # Listing does not write project data. + self.second.edit('epoch', self.second.first, ['new'], author=author['profile_uuid']) + self.assertEqual(self.second.records.rows[0]['author_name'], 'Scientist') + self.assertEqual(self.second.records.rows[0]['profile_uuid'], author['profile_uuid']) + self.assertEqual(self.first.records.rows, before) + + def test_master_roster_keeps_previous_authors_available_and_can_switch_back(self): + alice = self.first.store.create_profile('Alice', 'local') + bob = self.first.store.create_profile('Bob', 'local') + self.first.store.select_profile(alice['profile_uuid'], 'local') + self.first.store.select_profile(bob['profile_uuid'], 'local') + self.assertEqual({profile['profile_uuid'] for profile in author_profiles()}, {alice['profile_uuid'], bob['profile_uuid']}) + self.assertEqual(self.second.store.create_profile('Alice', 'local')['profile_uuid'], alice['profile_uuid']) + self.second.store.select_profile(alice['profile_uuid'], 'local') + self.assertEqual(self.first.store.list_profiles()['selected_profile_uuid'], alice['profile_uuid']) + + def test_invalid_selection_does_not_change_master_preference(self): + author = self.first.store.create_profile('Scientist', 'local') + self.first.store.select_profile(author['profile_uuid'], 'local') + before = selected_author() + result = self.second.client.post('/api/annotation-profiles/selected', json={'profile_uuid': 'bad'}, headers=self.second.headers) + self.assertEqual(result.status_code, 400) + self.assertEqual(selected_author(), before) + + def test_full_master_roster_with_unicode_names_can_be_reopened(self): + for index in range(100): + author = {'profile_uuid': str(uuid.uuid4()), 'display_name': '神' * 117 + str(index)} + remember_author(author) + self.assertEqual(len(author_profiles()), 100) + self.assertEqual(selected_author(), author) + with self.assertRaisesRegex(ValueError, 'at most 100'): + remember_author({'profile_uuid': str(uuid.uuid4()), 'display_name': 'One too many'}) + self.assertEqual(selected_author(), author) diff --git a/python/tests/test_workspace_frontend.py b/python/tests/test_workspace_frontend.py new file mode 100644 index 0000000..8695030 --- /dev/null +++ b/python/tests/test_workspace_frontend.py @@ -0,0 +1,29 @@ +import json +from pathlib import Path +import tempfile +import unittest +from flask import Flask +from workspace_frontend import project_frontend_response + + +class FrontendProjectBootstrapTests(unittest.TestCase): + def test_sidebar_is_seeded_before_scripts_without_injecting_project_names(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / 'index.html').write_text('') + (root / 'asset.js').write_text('asset') + app = Flask(__name__) + data = {'projects': [{'name': '', 'path': '/a', 'uuid': 'a'}], 'launcher': False} + with app.test_request_context('/'): + response = project_frontend_response(root, 'index.html', lambda: data) + html = response.get_data(as_text=True) + payload = html.split('id="rieke-projects-bootstrap">')[1].split('')[0] + self.assertEqual(json.loads(payload), data) + self.assertEqual(html.count(''), 1) + self.assertEqual(response.headers['Cache-Control'], 'no-store') + self.assertNotIn('ETag', response.headers) + with app.test_request_context('/asset.js'): + response = project_frontend_response(root, 'asset.js', lambda: self.fail('asset requested inventory')) + response.direct_passthrough = False + self.assertEqual(response.get_data(as_text=True), 'asset') + response.close() diff --git a/python/tests/test_workspace_projects.py b/python/tests/test_workspace_projects.py index 5616d4c..ff369c9 100644 --- a/python/tests/test_workspace_projects.py +++ b/python/tests/test_workspace_projects.py @@ -28,7 +28,7 @@ def make_project(self, folder, name, identity=None, display_name=None): 'connection': {'password': 'must never leave registry'}, 'database': 'fixture'})) return path - def test_current_first_display_name_preferred_and_no_connection_contents_returned(self): + def test_stable_order_display_name_preferred_and_no_connection_contents_returned(self): other = self.make_project('other', 'Internal name', display_name='Spike Response Model') before = (self.current / 'project.json').stat().st_mtime_ns result = list_projects(self.current) @@ -53,9 +53,48 @@ def test_native_copies_remain_selectable_by_folder(self): (path/'catalog.json').write_text(json.dumps(catalog)) result=list_projects(self.current) self.assertEqual(len(result['projects']),2) - self.assertEqual([p['current'] for p in result['projects']],[True,False]) + self.assertEqual([p['current'] for p in result['projects']],[False,True]) self.assertEqual(len(list_managed_projects(self.root)['projects']),2) + def test_switching_current_project_never_promotes_it(self): + from workspace_projects import list_managed_projects + other = self.make_project('alpha', 'Alpha') + third = self.make_project('zeta', 'Zeta') + expected = [str(other.resolve()), str(self.current.resolve()), str(third.resolve())] + for current in (self.current, other, third): + result = list_projects(current) + self.assertEqual([row['path'] for row in result['projects']], expected) + self.assertEqual(next(row['path'] for row in result['projects'] if row['current']), str(current.resolve())) + self.assertEqual([row['path'] for row in list_managed_projects(self.root, current)['projects']], expected) + + def test_drag_order_survives_switches_and_last_project_updates(self): + from workspace_startup_registry import remember_project, remember_project_order + other = self.make_project('alpha', 'Alpha') + paths = [str(self.current.resolve()), str(other.resolve())] + remember_project_order(self.root, paths) + remember_project(self.root, json.loads((other / 'project.json').read_text())['project_uuid']) + for current in (self.current, other): + self.assertEqual([row['path'] for row in list_projects(current)['projects']], paths) + third = self.make_project('new', 'A new project') + self.assertEqual([row['path'] for row in list_projects(other)['projects']], paths + [str(third.resolve())]) + + def test_order_route_accepts_only_a_complete_validated_folder_list(self): + from flask import Flask, jsonify + from workspace_launcher import register_project_routes + app = Flask(__name__) + app.register_error_handler(ValueError, lambda error: (jsonify(error=str(error)), 400)) + register_project_routes(app, retinanalysis_dir=self.root, project_dir=self.current) + other = self.make_project('alpha', 'Alpha') + client = app.test_client() + paths = [str(self.current.resolve()), str(other.resolve())] + result = client.post('/api/projects/order', json={'paths': paths}) + self.assertEqual(result.status_code, 200, result.get_json()) + self.assertEqual([row['path'] for row in result.get_json()['projects']], paths) + before = (self.root / '.rieke-os.json').read_text() + for invalid in [paths[:1], [paths[0], paths[0]], paths + ['/outside'], 'bad', [1], None]: + self.assertEqual(client.post('/api/projects/order', json={'paths': invalid}).status_code, 400) + self.assertEqual((self.root / '.rieke-os.json').read_text(), before) + def test_only_valid_immediate_siblings_are_discovered(self): self.make_project('container/nested', 'Nested project') (self.root / 'empty').mkdir() @@ -111,7 +150,7 @@ def test_display_name_sort_is_deterministic_and_invalid_identity_fails_closed(se self.make_project('a', 'Beta') invalid = self.make_project('invalid-id', 'Bad identity', 'not-a-uuid') self.assertEqual([row['name'] for row in list_projects(self.current)['projects']], - ['Current project', 'Alpha', 'Beta']) + ['Alpha', 'Beta', 'Current project']) with self.assertRaises(ValueError): list_projects(invalid) diff --git a/python/workspace_annotations.py b/python/workspace_annotations.py index 030ae2f..fc22c0e 100644 --- a/python/workspace_annotations.py +++ b/python/workspace_annotations.py @@ -12,6 +12,7 @@ import time import uuid from collections import Counter +from workspace_author_preferences import selected_author, author_profiles, remember_author from recording_workspace import workspace_tables from workspace_audit import build_audit_payload @@ -107,8 +108,12 @@ def _event(self,action,actor,payload): def list_profiles(self): rows=(self.Profile&{'project_uuid':self.project_uuid}).to_dicts() profiles={row['profile_uuid']:{'profile_uuid':row['profile_uuid'],'display_name':row['display_name'],'local':True} for row in rows} - default=self.default_profile;profiles.setdefault(default['profile_uuid'],default) - return {'profiles':sorted(profiles.values(),key=lambda p:(p['display_name'].casefold(),p['profile_uuid'])), + default=self.default_profile;master=author_profiles() + if not any(profile['display_name'].casefold()==default['display_name'].casefold() for profile in master): + profiles.setdefault(default['profile_uuid'],default) + for profile in master:profiles.setdefault(profile['profile_uuid'],{**profile,'local':True}) + preferred=selected_author() + return {'selected_profile_uuid':preferred['profile_uuid'] if preferred else None,'profiles':sorted(profiles.values(),key=lambda p:(p['display_name'].casefold(),p['profile_uuid'])), 'default_profile_uuid':default['profile_uuid'],'attribution':'local_profile_not_authentication'} def _ensure_profiles(self,profiles,actor,pending=None): @@ -141,6 +146,15 @@ def create_profile(self,name,actor): self._event('annotation_profile_created',actor,{'profile_uuid':key,'display_name':name,'attribution':'local_profile_not_authentication'}) return {'profile_uuid':key,'display_name':name,'local':True} + def select_profile(self, key, actor): + key=identity(key) + profile=next((row for row in self.list_profiles()['profiles'] if row['profile_uuid']==key),None) + if not profile:raise ValueError('Choose an existing tag author') + with self.lock(),self.dj.conn().transaction: + self._ensure_profiles([{field:profile[field] for field in ('profile_uuid','display_name')}],text(actor)) + remember_author(profile) + return {'profile':profile,'selected_profile_uuid':key} + def _scope(self,kind,ids,maximum=MAX_TARGETS): ready=getattr(self.service,'_ready',None) if ready:ready() @@ -268,6 +282,10 @@ def apply_batch(self,operations,actor,profiles=None,audit_context=None,external_ default=self.default_profile if any(key[2]==default['profile_uuid'] for key,_,_,_ in parsed) and not any(p.get('profile_uuid')==default['profile_uuid'] for p in incoming): incoming.append({k:default[k] for k in ('profile_uuid','display_name')}) + used_authors={key[2] for key,_,_,_ in parsed} + for profile in author_profiles(): + if profile['profile_uuid'] in used_authors and not any(p.get('profile_uuid')==profile['profile_uuid'] for p in incoming): + incoming.append(profile) pending_profiles=[] authors=self._ensure_profiles(incoming,actor,pending_profiles) saved={(row['target_kind'],row['target_uuid'],row['profile_uuid']):row for row in self._rows()} @@ -333,6 +351,10 @@ def annotation_profiles(): def annotation_profile_create(): value=body({'display_name'}) with db_lock:return jsonify(store.create_profile(value.get('display_name'),actor())),201 + @app.post('/api/annotation-profiles/selected') + def annotation_profile_select(): + value=body({'profile_uuid'}) + with db_lock:return jsonify(store.select_profile(value.get('profile_uuid'),actor())) @app.get('/api/annotation-tags') def annotation_suggestions(): if set(request.args)-{'q','limit'} or any(len(v)!=1 for _,v in request.args.lists()):raise ValueError('Invalid tag suggestion query') diff --git a/python/workspace_api.py b/python/workspace_api.py index 8461a1a..9ea819e 100644 --- a/python/workspace_api.py +++ b/python/workspace_api.py @@ -1457,7 +1457,9 @@ def ui(path="index.html"): return jsonify(error="Unknown API endpoint"), 404 if not frontend.exists(): return "Build workspace-app first with npm run build.", 503 - return send_from_directory(frontend, path) + from workspace_frontend import project_frontend_response + from workspace_projects import list_projects + return project_frontend_response(frontend, path, lambda: list_projects(project_dir)) if shared_annotations: from workspace_external_tags import register_external_tag_routes diff --git a/python/workspace_author_preferences.py b/python/workspace_author_preferences.py new file mode 100644 index 0000000..2e35953 --- /dev/null +++ b/python/workspace_author_preferences.py @@ -0,0 +1,71 @@ +"""The selected tag author is a preference of this local OS user, across projects.""" +import fcntl +import json +import os +from pathlib import Path +import uuid +from recording_workspace import write_json + + +def preference_path(): + return Path(os.environ.get('RIEKE_PREFERENCES_DIR', Path.home() / '.rieke-os')).expanduser() / 'annotation-author.json' + + +def validate_profile(profile): + if not isinstance(profile, dict): + raise ValueError('Choose a valid tag author') + identity = str(uuid.UUID(profile.get('profile_uuid', ''))) + name = profile.get('display_name') + if not isinstance(name, str) or not 1 <= len(name.strip()) <= 120 or any(ord(char) < 32 for char in name): + raise ValueError('Tag author names require 1–120 printable characters') + return {'profile_uuid': identity, 'display_name': name.strip()} + + +def author_preferences(): + path = preference_path() + if path.is_symlink(): + raise ValueError('Tag author preference cannot be a symbolic link') + if not path.exists(): + return {'profiles': [], 'selected': None} + if path.stat().st_size > 131072: + raise ValueError('Tag author preference is unexpectedly large') + value = json.loads(path.read_text()) + if not isinstance(value, dict) or value.get('format') != 'rieke-tag-author' or type(value.get('version')) is not int or value['version'] != 1: + raise ValueError('Tag author preference is invalid') + selected = validate_profile(value.get('profile')) + profiles = value.get('profiles', [selected]) + if not isinstance(profiles, list) or len(profiles) > 100: + raise ValueError('Use at most 100 master author profiles') + profiles = [validate_profile(profile) for profile in profiles] + if len({profile['profile_uuid'] for profile in profiles}) != len(profiles) or selected not in profiles: + raise ValueError('Master author profile identities are inconsistent') + return {'profiles': profiles, 'selected': selected} + + +def selected_author(): + return author_preferences()['selected'] + + +def author_profiles(): + return author_preferences()['profiles'] + + +def remember_author(profile): + profile = validate_profile(profile) + path = preference_path() + path.parent.mkdir(parents=True, exist_ok=True) + lock_path = path.with_suffix('.lock') + if path.is_symlink() or lock_path.is_symlink(): + raise ValueError('Tag author preference cannot be a symbolic link') + with lock_path.open('a') as lock: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + profiles = author_profiles() + existing = next((saved for saved in profiles if saved['profile_uuid'] == profile['profile_uuid']), None) + if existing and existing != profile: + raise ValueError('This author identity already has another name') + if not existing: + if len(profiles) >= 100: + raise ValueError('Use at most 100 master author profiles') + profiles.append(profile) + write_json(path, {'format': 'rieke-tag-author', 'version': 1, 'profile': profile, 'profiles': profiles}) + return profile diff --git a/python/workspace_frontend.py b/python/workspace_frontend.py new file mode 100644 index 0000000..eecd491 --- /dev/null +++ b/python/workspace_frontend.py @@ -0,0 +1,17 @@ +"""Seed project navigation before React paints on each local project address.""" +import json +from flask import Response, send_from_directory + + +def project_frontend_response(frontend, path, inventory): + if path != 'index.html': + return send_from_directory(frontend, path) + response = send_from_directory(frontend, path, conditional=False) + response.direct_passthrough = False + data = json.dumps(inventory(), ensure_ascii=True).replace('<', '\\u003c') + bootstrap = '' + try: + html = response.get_data(as_text=True).replace('', bootstrap + '', 1) + finally: + response.close() + return Response(html, mimetype='text/html', headers={'Cache-Control': 'no-store'}) diff --git a/python/workspace_launcher.py b/python/workspace_launcher.py index 01ff973..ebc4bff 100644 --- a/python/workspace_launcher.py +++ b/python/workspace_launcher.py @@ -21,6 +21,22 @@ def project_inventory(): result = list_projects(current) if current else list_managed_projects(root_provider()) return jsonify({**result, 'launcher': current is None}) + @app.post('/api/projects/order') + def project_order(): + body = request.get_json(silent=True) + if request.args or not isinstance(body, dict) or set(body) != {'paths'}: + raise ValueError('Project order requires the complete project folder list') + paths = body['paths'] + if not isinstance(paths, list) or len(paths) > 1000 or any(not isinstance(path, str) or len(path) > 4096 for path in paths): + raise ValueError('Project order must contain validated project folders') + inventory = list_projects(current) if current else list_managed_projects(root_provider()) + available = {project['path'] for project in inventory['projects']} + if len(paths) != len(available) or len(set(paths)) != len(paths) or set(paths) != available: + raise ValueError('Project list changed; refresh it before rearranging projects') + from workspace_startup_registry import remember_project_order + remember_project_order(current.parent if current else root_provider(), paths) + return project_inventory() + @app.post('/api/projects') def project_create(): body = request.get_json(silent=True) @@ -158,7 +174,8 @@ def choose_workspace(): def frontend_page(path='index.html'): if path.startswith('api/'): return jsonify(error='Open a project before using project operations.'),404 - return send_from_directory(frontend,path) + from workspace_frontend import project_frontend_response + return project_frontend_response(frontend, path, lambda: {**list_managed_projects(selected_root[0]), 'launcher': True}) return app diff --git a/python/workspace_projects.py b/python/workspace_projects.py index 05198cc..1f07329 100644 --- a/python/workspace_projects.py +++ b/python/workspace_projects.py @@ -91,9 +91,9 @@ def list_projects(project_dir): occurrences[candidate['uuid']] = occurrences.get(candidate['uuid'], 0) + 1 projects = [candidate for candidate in candidates if candidate.get('database_kind') == 'native-mysql' or occurrences[candidate['uuid']] == 1] projects.sort(key=lambda item: (item['name'].casefold(), item['name'], item['path'])) - from workspace_startup_registry import read_registry + from workspace_startup_registry import read_registry, ordered_projects saved = read_registry(current_dir.parent) - return {'current_project_uuid': current['uuid'], 'projects': [current, *projects], + return {'current_project_uuid': current['uuid'], 'projects': ordered_projects([current, *projects], saved), 'managed_root': str(current_dir.parent), 'last_project_uuid': saved.get('last_project_uuid'), 'launcher': False} @@ -122,12 +122,12 @@ def list_managed_projects(root, current_project=None): for project in projects: counts[project['uuid']] = counts.get(project['uuid'], 0) + 1 projects = [p for p in projects if p.get('database_kind') == 'native-mysql' or counts[p['uuid']] == 1] - projects.sort(key=lambda p: (not p['current'], p['name'].casefold(), p['path'])) - from workspace_startup_registry import read_registry + projects.sort(key=lambda p: (p['name'].casefold(), p['name'], p['path'])) + from workspace_startup_registry import read_registry, ordered_projects saved = read_registry(root) identities = {p['uuid'] for p in projects} return {'current_project_uuid': next((p['uuid'] for p in projects if p['current']), None), - 'projects': projects, 'managed_root': str(root), + 'projects': ordered_projects(projects, saved), 'managed_root': str(root), 'workspace_initialized': (root / '.rieke-workspace.json').is_file(), 'last_project_uuid': saved.get('last_project_uuid') if saved.get('last_project_uuid') in identities and counts.get(saved.get('last_project_uuid')) == 1 else None} diff --git a/python/workspace_startup_registry.py b/python/workspace_startup_registry.py index b5d3e39..70f15a2 100644 --- a/python/workspace_startup_registry.py +++ b/python/workspace_startup_registry.py @@ -2,6 +2,7 @@ from pathlib import Path import json import uuid +import fcntl from recording_workspace import write_json @@ -21,7 +22,30 @@ def read_registry(root): return value +def _update_registry(root, changes): + root = Path(root) + lock_path = root / '.rieke-os.lock' + if lock_path.is_symlink(): + raise ValueError('Startup registry lock cannot be a symbolic link') + with lock_path.open('a') as lock: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + value = {**read_registry(root), **changes} + if len(json.dumps(value).encode()) > 64 * 1024: + raise ValueError('Startup registry is unexpectedly large') + write_json(root / '.rieke-os.json', value) + + def remember_project(root, identity): - value = read_registry(root) - value['last_project_uuid'] = str(uuid.UUID(identity)) - write_json(Path(root) / '.rieke-os.json', value) + _update_registry(root, {'last_project_uuid': str(uuid.UUID(identity))}) + + +def remember_project_order(root, paths): + _update_registry(root, {'project_order': paths}) + + +def ordered_projects(projects, saved): + # A folder identifies an independent native copy even when UUIDs match. + paths = saved.get('project_order') + ranks = {path: index for index, path in enumerate(paths)} if isinstance(paths, list) and all(isinstance(path, str) for path in paths) else {} + return sorted(projects, key=lambda item: (ranks.get(item['path'], len(ranks)), + item['name'].casefold(), item['name'], item['path'])) diff --git a/workspace-app/src/App.jsx b/workspace-app/src/App.jsx index bff3fbd..567cf20 100644 --- a/workspace-app/src/App.jsx +++ b/workspace-app/src/App.jsx @@ -35,6 +35,7 @@ import {datedCellLabel} from './recordingIdentity.js'; import Overview from './components/Overview.jsx'; import ProtocolExports,{ExportDestination} from './components/ProtocolExports.jsx'; import ProjectNavigator,{ProjectRail} from './components/ProjectNavigator.jsx'; +import {projectBootstrap} from './projectNavigation.js'; import useWorkspaceNavigation from './useWorkspaceNavigation.js'; import {exportReuseRoute} from './exportReuse.js'; import {resolveProtocolSession,restoredEpochFocus} from './workspaceNavigation.js'; @@ -45,7 +46,7 @@ function ActivityList({events=[]}) {return events.length?
{setup}
{folderOpen&&setFolderOpen(false)} onTransferComplete={()=>registry.reload()}/>};return
/ {page==='protocol'?humanize(data?.protocols?.find(p=>p.protocol_uuid===protocol)?.name):page==='explore'?'Search predicate':page==='import'?'Add data store':page==='stores'?'Data stores':page==='cell-qc'?'Cell QC':humanize(page)}openQC(result.cell_uuid,result.epoch_uuid)} onPredicate={searchPredicate}/> Local workspace
{reviewImportJob&&}setPage('import')}/>
{folderOpen&&setFolderOpen(false)} onFiles={()=>setPage('files')}/>}{projectSetup&&
{setup}
}{openingProject&&
Opening {openingProject}…
}{projectError&&
{projectError}
}{page==='cell-qc'?navigation.canBack?navigation.back():setPage('overview')}/>:page==='stores'?setPage('import')} onProtocol={navigate} onFiles={()=>setPage('files')} onLogs={()=>setPage('activity')}/>:!data?:page==='overview'?setPage('import')} onExplore={()=>explore()}/>:page==='explore'?explore(null,{},true)} onExit={()=>explore()} key={route.key} session={sessions.current.get(route.key) || (route.resumeExplorer?lastExplorerSession.current:null)} onSession={rememberExplorer} initialPredicate={explorePredicate} initialRevisionId={exploreRevisionId} initialExportIntent={exploreExportIntent} initialProtocolId={exploreTargetId} onProtocolApplied={protocolApplied} onChange={changed} revision={revision} protocols={data.protocols || []} onInspect={openInspection}/>:page==='protocol'?item.protocol_uuid===protocol)} suggestionsError={suggestionsResource.error} onRetrySuggestions={suggestionsResource.reload} initialRecipe={initialRecipe} initialInspection={initialInspection} onRefine={explore} onReviewCandidate={reviewCandidate} onStores={()=>setPage('stores')} id={protocol} revision={revision} onChange={changed} onImport={()=>setPage('import')}/>:page==='activity'?:page==='files'?setPage('stores')}/>:page==='import'?setPage('stores')} jobs={importJobs} suggestions={suggestionsResource.data?.suggestions||[]} approvedHistory={suggestionsResource.data?.approved_history||[]} suggestionsError={suggestionsResource.error} onRetrySuggestions={suggestionsResource.reload} onReviewCandidate={reviewCandidate} onProtocol={navigate}/>:}
;} + const data=overview.data;const projectId=registry.data?.current_project_uuid || data?.project?.project_uuid;const registeredProjects=registry.data?.projects || [];const currentProject=registeredProjects.find(project=>project.current)||registeredProjects.find(project=>project.uuid===projectId);const projectName=data?.project?.display_name || currentProject?.name || data?.project?.name || 'Project';useEffect(()=>{document.title=isLauncher?'Rieke OS · Projects':`${projectName} · Rieke OS`;},[projectName,isLauncher]);async function selectProject(project){if(openingProject||!project.available)return;if(project.current){setPage('overview');return;}setOpeningProject(project.name);setProjectError('');try{const result=await api(project.path?'/projects/open-folder':`/projects/${project.uuid}/open`,{method:'POST',body:project.path?{directory:project.path}:{}});if(typeof result.url!=='string')throw new Error('The project service did not return an app URL.');const destination=new URL(result.url,window.location.href);if(!['http:','https:'].includes(destination.protocol)||!['127.0.0.1','localhost','[::1]'].includes(destination.hostname))throw new Error('The project service returned an invalid local URL.');window.location.assign(destination.href);}catch(error){setProjectError(error.message);setOpeningProject(null);}}const setup={setProjectSetup(false);setFolderOpen(true);}} onWorkspaceChanged={registry.reload} registry={registry.data} onSelect={selectProject} onCreated={async project=>{registry.reload();await selectProject(project);}} onCancel={isLauncher?undefined:()=>setProjectSetup(false)} createOnly={false} loading={!!openingProject} error={projectError||registry.error}/>;if(isLauncher)return
{setup}
{folderOpen&&setFolderOpen(false)} onTransferComplete={()=>registry.reload()}/>}
;return
/ {page==='protocol'?humanize(data?.protocols?.find(p=>p.protocol_uuid===protocol)?.name):page==='explore'?'Search predicate':page==='import'?'Add data store':page==='stores'?'Data stores':page==='cell-qc'?'Cell QC':humanize(page)}openQC(result.cell_uuid,result.epoch_uuid)} onPredicate={searchPredicate}/> Local workspace
{reviewImportJob&&}setPage('import')}/>
{folderOpen&&setFolderOpen(false)} onFiles={()=>setPage('files')}/>}{projectSetup&&
{setup}
}{openingProject&&
Opening {openingProject}…
}{projectError&&
{projectError}
}{page==='cell-qc'?navigation.canBack?navigation.back():setPage('overview')}/>:page==='stores'?setPage('import')} onProtocol={navigate} onFiles={()=>setPage('files')} onLogs={()=>setPage('activity')}/>:!data?:page==='overview'?setPage('import')} onExplore={()=>explore()}/>:page==='explore'?explore(null,{},true)} onExit={()=>explore()} key={route.key} session={sessions.current.get(route.key) || (route.resumeExplorer?lastExplorerSession.current:null)} onSession={rememberExplorer} initialPredicate={explorePredicate} initialRevisionId={exploreRevisionId} initialExportIntent={exploreExportIntent} initialProtocolId={exploreTargetId} onProtocolApplied={protocolApplied} onChange={changed} revision={revision} protocols={data.protocols || []} onInspect={openInspection}/>:page==='protocol'?item.protocol_uuid===protocol)} suggestionsError={suggestionsResource.error} onRetrySuggestions={suggestionsResource.reload} initialRecipe={initialRecipe} initialInspection={initialInspection} onRefine={explore} onReviewCandidate={reviewCandidate} onStores={()=>setPage('stores')} id={protocol} revision={revision} onChange={changed} onImport={()=>setPage('import')}/>:page==='activity'?:page==='files'?setPage('stores')}/>:page==='import'?setPage('stores')} jobs={importJobs} suggestions={suggestionsResource.data?.suggestions||[]} approvedHistory={suggestionsResource.data?.approved_history||[]} suggestionsError={suggestionsResource.error} onRetrySuggestions={suggestionsResource.reload} onReviewCandidate={reviewCandidate} onProtocol={navigate}/>:}
;} diff --git a/workspace-app/src/annotationProfile.js b/workspace-app/src/annotationProfile.js index d2caa96..1f259b6 100644 --- a/workspace-app/src/annotationProfile.js +++ b/workspace-app/src/annotationProfile.js @@ -2,14 +2,28 @@ import {createContext,createElement,useContext,useEffect,useState} from 'react'; import {api,useResource} from './api.js'; const Context=createContext(null); export function AnnotationProfileProvider({projectId,children}){ - const resource=useResource(projectId?'/annotation-profiles':null); + const resource=useResource(projectId?'/annotation-profiles':null,projectId); const key=`workspace.annotation-profile.${projectId}`; - const [selected,setSelected]=useState(''),[profileOpen,setProfileOpen]=useState(false); + const [selected,setSelected]=useState(''),[profileOpen,setProfileOpen]=useState(false),[preferenceError,setPreferenceError]=useState(''); useEffect(()=>{try{setSelected(localStorage.getItem(key)||'');}catch{setSelected('');}},[key]); const profiles=resource.data?.profiles||[]; - const profile=profiles.find(item=>item.profile_uuid===selected); - function selectProfile(uuid){if(!profiles.some(item=>item.profile_uuid===uuid))return;setSelected(uuid);try{localStorage.setItem(key,uuid);}catch{}} - async function createProfile(display_name){const result=await api('/annotation-profiles',{method:'POST',body:{display_name}});const row=result.profile||result;setSelected(row.profile_uuid);try{localStorage.setItem(key,row.profile_uuid);}catch{}resource.reload();return row;} - return createElement(Context.Provider,{value:{profileOpen,openProfile:()=>setProfileOpen(true),closeProfile:()=>setProfileOpen(false),profileUuid:profile?.profile_uuid||'',profileName:profile?.display_name||'',profiles,loading:!projectId||resource.loading,error:resource.error,selectProfile,createProfile,reload:resource.reload}},children); + const profile=profiles.find(item=>item.profile_uuid===(resource.data?.selected_profile_uuid||selected)); + async function rememberChoice(uuid){ + const result=await api('/annotation-profiles/selected',{method:'POST',body:{profile_uuid:uuid}}); + setSelected(result.selected_profile_uuid);setPreferenceError(''); + try{localStorage.setItem(key,result.selected_profile_uuid);}catch{} + resource.reload();return result.profile; + } + async function selectProfile(uuid){if(!profiles.some(item=>item.profile_uuid===uuid))throw new Error('Choose an existing tag author.');return rememberChoice(uuid);} + async function createProfile(display_name){const result=await api('/annotation-profiles',{method:'POST',body:{display_name}});const row=result.profile||result;await rememberChoice(row.profile_uuid);return row;} + // Adopt an existing browser choice once; future project addresses use the + // selected author supplied by the machine preference, without another prompt. + useEffect(()=>{ + if(resource.loading||resource.error||resource.data?.selected_profile_uuid||!selected||!profiles.some(item=>item.profile_uuid===selected))return; + let cancelled=false; + api('/annotation-profiles/selected',{method:'POST',body:{profile_uuid:selected}}).then(()=>{if(!cancelled)resource.reload();}).catch(error=>{if(!cancelled)setPreferenceError(error.message);}); + return()=>{cancelled=true;}; + },[resource.loading,resource.error,resource.data?.selected_profile_uuid,selected,projectId]); + return createElement(Context.Provider,{value:{profileOpen,openProfile:()=>setProfileOpen(true),closeProfile:()=>setProfileOpen(false),profileUuid:profile?.profile_uuid||'',profileName:profile?.display_name||'',profiles,loading:!projectId||resource.loading,error:resource.error||preferenceError,selectProfile,createProfile,reload:resource.reload}},children); } export function useAnnotationProfile(){return useContext(Context)||{profiles:[],profileUuid:'',profileName:'',loading:false,error:'Tag profile is unavailable.'};} diff --git a/workspace-app/src/components/AnnotationProfile.jsx b/workspace-app/src/components/AnnotationProfile.jsx index 545ab33..903fa17 100644 --- a/workspace-app/src/components/AnnotationProfile.jsx +++ b/workspace-app/src/components/AnnotationProfile.jsx @@ -11,6 +11,7 @@ export default function AnnotationProfile(){ function ProfileDialog({profile,onClose}){ const dialog=useRef(null),[name,setName]=useState(''),[busy,setBusy]=useState(false),[error,setError]=useState(''); useEffect(()=>{const element=dialog.current;element.showModal();return()=>element.close();},[]); + async function choose(uuid){if(busy)return;setBusy(true);setError('');try{await profile.selectProfile(uuid);onClose();}catch(error){setError(error.message);}finally{setBusy(false);}} async function create(event){event.preventDefault();if(busy||!name.trim())return;setBusy(true);setError('');try{await profile.createProfile(name.trim());onClose();}catch(error){setError(error.message);}finally{setBusy(false);}} - return {event.preventDefault();if(!busy)onClose();}}>

Tag author

Choose or create the author recorded on your tags. This choice is remembered for this project in this browser. You can browse without choosing; tagging requires a profile.

{profile.loading?

Loading profiles…

:profile.profiles.map(item=>)}
{(error||profile.error)&&

{error||profile.error}{profile.error&&}

}
; + return {event.preventDefault();if(!busy)onClose();}}>

Tag author

Choose or create the author recorded on your tags. Your choice is remembered on this computer and used across projects. Existing tags keep their original author. You can browse without choosing; tagging requires a profile.

{profile.loading?

Loading profiles…

:profile.profiles.map(item=>)}
{(error||profile.error)&&

{error||profile.error}{profile.error&&}

}
; } diff --git a/workspace-app/src/components/ProjectNavigator.css b/workspace-app/src/components/ProjectNavigator.css index 1ef2c13..e14c3a0 100644 --- a/workspace-app/src/components/ProjectNavigator.css +++ b/workspace-app/src/components/ProjectNavigator.css @@ -1,9 +1,15 @@ -.project-rail-list{display:flex;flex-direction:column;align-items:center;gap:10px;width:100%;padding:5px 0;min-height:0;overflow-y:auto;scrollbar-width:thin}.project-rail-list .project-rail-item{display:flex;position:relative;align-items:center;justify-content:center;width:38px;height:38px;min-height:38px;padding:0;background:rgba(255,255,255,.11);border:1px solid rgba(255,255,255,.13);border-radius:10px;color:#fff;box-shadow:none;font-size:11px;font-weight:650;letter-spacing:.3px;line-height:1}.project-rail-list .project-rail-item:hover{background:rgba(255,255,255,.2)}.project-rail-list .project-rail-item.current{background:#faf7fd;color:#604774;border-color:#fff;box-shadow:0 0 0 2px rgba(233,220,245,.25)}.project-rail-list .project-rail-item:focus-visible{outline:2px solid #fff;outline-offset:4px}.project-rail-list .project-rail-item:disabled{opacity:.6;cursor:default}.project-rail-loading{color:#e9ddf2;padding:9px}.project-navigator{padding:14px 11px 13px;border-bottom:1px solid var(--border,#ded7e4)}.project-navigator-trigger{display:flex;align-items:center;gap:9px;padding:8px;width:100%;text-align:left;background:transparent;border:1px solid transparent;border-radius:7px;min-width:0}.project-navigator-trigger:hover{background:rgba(255,255,255,.52);border-color:#dcd2e3}.project-nav-monogram{height:34px;min-width:34px;max-width:34px;display:flex;align-items:center;justify-content:center;background:#e9deef;color:#654579;border:1px solid #d4c3df;border-radius:8px;font-size:11px;letter-spacing:.2px;font-weight:650}.project-nav-label{flex:1;min-width:0}.project-nav-label small{display:block;font-size:10px;letter-spacing:1.05px;color:var(--ink-muted,#706478);margin-bottom:5px}.project-nav-label strong{display:block;font-size:13px;font-weight:600;line-height:1.5;color:var(--ink,#302b36);overflow-wrap:anywhere}.project-navigator-trigger>svg{flex-shrink:0;color:var(--ink-secondary,#62566d)}.project-navigator-menu{position:fixed;z-index:1000;background:#fff;border:1px solid var(--border,#ded7e4);box-shadow:0 12px 36px rgba(51,34,64,.15);border-radius:10px;overflow:auto;padding:8px;outline:none;color:var(--ink-secondary,#62566d)}.project-menu-heading{display:flex;justify-content:space-between;align-items:center;padding:5px 7px 10px;font-size:12px;font-weight:550;color:var(--ink,#302b36)}.project-menu-heading button{padding:4px 5px;border:0;background:transparent}.project-menu-item{display:flex;align-items:center;gap:10px;border:1px solid transparent;width:100%;text-align:left;padding:10px 8px;background:transparent;border-radius:6px;color:var(--ink-secondary,#62566d);min-width:0}.project-menu-item.focused,.project-menu-item:hover{background:#f6f0fa;border-color:#e8ddef}.project-menu-item[aria-selected=true]{background:#f1e8f7}.project-menu-item[aria-disabled=true]{opacity:.65;cursor:default}.project-menu-item>span:nth-child(2){flex:1;min-width:0}.project-menu-item strong{display:block;font-size:13px;line-height:1.55;font-weight:550;color:var(--ink,#302b36);overflow-wrap:anywhere}.project-menu-item small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:var(--ink-muted,#706478);margin-top:4px}.project-menu-item em{display:block;font-size:12px;line-height:1.55;color:var(--warning-ink,#78531d);font-style:normal;margin-top:4px}.project-menu-item>svg{color:var(--accent,#67447d);flex-shrink:0}.project-menu-note{font-size:12px;line-height:1.6;padding:11px 7px 4px;margin-top:6px;border-top:1px solid #eee6f3;color:var(--ink-muted,#706478)}.project-menu-empty,.project-menu-error{font-size:12px;line-height:1.6;padding:10px 7px;color:var(--ink-muted,#706478)}.project-menu-error{color:var(--error-ink,#963f3f)} - +.project-rail-list{display:flex;flex-direction:column;align-items:center;gap:10px;width:100%;padding:5px 0;min-height:0;overflow-y:auto;scrollbar-width:thin} +.project-rail-list .project-rail-item{display:flex;position:relative;align-items:center;justify-content:center;width:38px;height:38px;min-height:38px;padding:0;background:rgba(255,255,255,.11);border:1px solid rgba(255,255,255,.13);border-radius:10px;color:#fff;box-shadow:none;font-size:11px;font-weight:650;letter-spacing:.3px;line-height:1;touch-action:pan-y} +.project-rail-list .project-rail-item>span{display:grid;place-items:center;width:32px;height:32px;border-radius:8px} +.project-rail-list .project-rail-item:hover{background:rgba(255,255,255,.2)} +.project-rail-list .project-rail-item.current{border-color:#dfcdef;background:#d9c4ec;box-shadow:0 0 0 2px rgba(233,220,245,.18)} +.project-rail-list .project-rail-item:focus-visible{outline:2px solid #fff;outline-offset:4px} +.project-rail-list .project-rail-item:disabled{opacity:1;cursor:default}.project-rail-list .project-rail-item.unavailable{opacity:.5} +.project-rail-list .project-drag-source{opacity:.45!important}.project-rail-list .project-drop-before{box-shadow:0 -4px 0 #dec4f1}.project-rail-list .project-drop-after{box-shadow:0 4px 0 #dec4f1} +.project-rail-list .project-rail-add{width:38px;height:38px;min-height:38px;padding:0;border:1px dashed #b9a6c6;background:transparent;color:#f1e8f7;border-radius:10px;margin-top:4px}.project-rail-list .project-rail-add:hover{background:#64506e}.project-rail-loading{color:#e9ddf2;padding:9px} +.project-navigator{padding:24px 18px;border-bottom:1px solid var(--border,#ded7e4)}.project-nav-title{display:block;font-size:14px;line-height:1.4;color:var(--ink,#302b36);overflow-wrap:anywhere}.project-nav-error{display:flex;gap:6px;align-items:center;margin-top:8px;font-size:11px;color:var(--ink-secondary)}.project-nav-error button{padding:3px;background:transparent;border:0} +.project-order-error{position:fixed;top:12px;right:12px;z-index:1000;display:flex;align-items:center;gap:10px;max-width:360px;padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--ink);font-size:12px}.project-order-error button{padding:3px;background:transparent;border:0} .project-switch-status,.project-switch-error{display:flex;align-items:center;gap:8px;padding:12px 20px;font-size:13px;line-height:1.6;color:var(--ink-secondary,#62566d);background:#f6f0fa;border-bottom:1px solid #e1d5e9}.project-switch-error{background:#fff1eb;color:var(--error-ink,#963f3f)}.project-switch-error button{margin-left:auto;padding:4px;border:0;background:transparent} -.project-rail-list .project-rail-item>span{display:grid;place-items:center;width:32px;height:32px;border-radius:8px}.project-rail-list .project-rail-add{width:38px;height:38px;min-height:38px;padding:0;border:1px dashed #b9a6c6;background:transparent;color:#f1e8f7;border-radius:10px;margin-top:4px}.project-rail-list .project-rail-add:hover{background:#64506e}.project-menu-add{width:100%;justify-content:flex-start;margin-bottom:10px} -.project-navigator-trigger{display:grid;grid-template-columns:34px minmax(0,1fr) 20px;column-gap:10px;align-items:center;padding:8px 6px} -.project-navigator-trigger>.project-nav-label{width:100%;min-width:0;text-align:left} -.project-navigator-trigger>.project-nav-label strong{white-space:normal;overflow-wrap:normal;word-break:normal;line-height:1.4} -.project-navigator-trigger>svg{position:static!important;justify-self:end;margin:0!important;transform:none;width:16px;height:16px} +.project-rail-status{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} +.project-nav-actions{display:grid;gap:7px;margin-top:15px}.project-nav-actions button{justify-content:flex-start;gap:8px;padding:8px 9px;font-size:11px;text-align:left;white-space:normal}.project-nav-actions svg{flex-shrink:0} diff --git a/workspace-app/src/components/ProjectNavigator.jsx b/workspace-app/src/components/ProjectNavigator.jsx index 0991449..c9db3b3 100644 --- a/workspace-app/src/components/ProjectNavigator.jsx +++ b/workspace-app/src/components/ProjectNavigator.jsx @@ -1,55 +1,46 @@ -import {useEffect,useId,useRef,useState} from 'react'; -import {createPortal} from 'react-dom'; -import {Check,ChevronDown,FolderOpen,LoaderCircle,RefreshCw,Plus} from 'lucide-react'; +import {useEffect,useRef,useState} from 'react'; +import {FolderOpen,LoaderCircle,RefreshCw,Plus,X} from 'lucide-react'; +import {api} from '../api.js'; +import {reorderIds} from '../ordering.js'; +import {startPointerDrag} from '../pointerDrag.js'; +import {orderedProjectRecords,projectKey} from '../projectNavigation.js'; import './ProjectNavigator.css'; function initials(name){ - const words=String(name || '').trim().split(/[\s_-]+/).filter(Boolean); - return words.length>1?words.slice(0,3).map(word=>Array.from(word)[0]).join('').toUpperCase():Array.from(words[0] || '?').slice(0,3).join('').toUpperCase(); + const words=String(name||'').trim().split(/[\s_-]+/).filter(Boolean); + return words.length>1?words.slice(0,3).map(word=>Array.from(word)[0]).join('').toUpperCase():Array.from(words[0]||'?').slice(0,3).join('').toUpperCase(); } -export function projectColor(identity){let hash=0;for(const char of String(identity||''))hash=(Math.imul(hash,31)+char.charCodeAt(0))|0;return {background:`hsl(${Math.abs(hash)%360} 32% 85%)`,color:`hsl(${Math.abs(hash)%360} 30% 25%)`};} -function currentProject(projects,identity){return projects.find(project=>project.current) || projects.find(project=>project.uuid===identity);} -export function ProjectRail({projects=[],currentProjectUuid,onSelect,onAddProject,loading=false,disabled=false}){ - return ; +export function projectColor(){return {background:'hsl(274 35% 89%)',color:'hsl(274 30% 30%)'};} +function currentProject(projects,identity){return projects.find(project=>project.current)||projects.find(project=>project.uuid===identity);} +export function ProjectRail({projects=[],currentProjectUuid,onSelect,onAddProject,onReorder,loading=false,disabled=false}){ + const [order,setOrder]=useState(null),[saving,setSaving]=useState(false),[error,setError]=useState(''); + const [dragging,setDragging]=useState(null),[dropSpot,setDropSpot]=useState(null),[announcement,setAnnouncement]=useState(''); + const visible=orderedProjectRecords(projects,order),ids=visible.map(projectKey); + const currentIds=useRef(ids),cancelDrag=useRef(null),suppressClick=useRef(false);currentIds.current=ids; + useEffect(()=>()=>cancelDrag.current?.(),[]); + async function save(next){ + if(saving||disabled||next===currentIds.current)return; + const before=order;setOrder(next);setSaving(true);setError(''); + try{const result=await api('/projects/order',{method:'POST',body:{paths:next}});setOrder(result.projects.map(projectKey));onReorder?.();setAnnouncement('Project order saved.');} + catch(error){setOrder(before);setError(error.message);onReorder?.();} + finally{setSaving(false);} + } + function pointerStart(event,project){ + suppressClick.current=false;cancelDrag.current?.(); + const root=event.currentTarget.closest('.project-rail-list'),source=projectKey(project); + cancelDrag.current=startPointerDrag(event,{ + getTarget:event=>{const row=root.ownerDocument.elementFromPoint(event.clientX,event.clientY)?.closest('[data-project-path]');if(!row||!root.contains(row))return null;const box=row.getBoundingClientRect();return {id:row.dataset.projectPath,placement:event.clientY{suppressClick.current=true;setDragging(source);},onTarget:setDropSpot, + onDrop:target=>save(reorderIds(currentIds.current,source,target.id,target.placement)), + onCancel:()=>setAnnouncement('Reordering cancelled.'),onFinish:()=>{cancelDrag.current=null;setDragging(null);setDropSpot(null);}, + }); + } + return <>{error&&
Project order could not be saved: {error}
}; } -export default function ProjectNavigator({projects=[],currentProjectUuid,onSelect,onAddProject,loading=false,disabled=false,error=null,onRetry}){ - const current=currentProject(projects,currentProjectUuid); - const [open,setOpen]=useState(false),[active,setActive]=useState(0),[position,setPosition]=useState(null); - const button=useRef(null),menu=useRef(null),listId=useId(); - function close(){setOpen(false);button.current?.focus();} - function choose(project){if(!project?.available||disabled)return;if(!(project.current??project.uuid===currentProjectUuid))onSelect?.(project);close();} - function move(direction){ - const available=projects.map((project,index)=>project.available?index:null).filter(index=>index!==null); - if(!available.length)return; - const current=available.indexOf(active),next=current<0?(direction>0?0:available.length-1):(current+direction+available.length)%available.length; - setActive(available[next]); - } - useEffect(()=>{setOpen(false);},[currentProjectUuid]); - useEffect(()=>{ - if(!open)return; - setActive(Math.max(0,projects.findIndex(project=>(project.current??project.uuid===currentProjectUuid)))); - function place(){const box=button.current.getBoundingClientRect();const width=Math.min(365,window.innerWidth-24);setPosition({left:Math.max(12,Math.min(box.left,window.innerWidth-width-12)),top:box.bottom+7,width,maxHeight:Math.max(140,window.innerHeight-box.bottom-23)});} - place();const click=event=>{if(!button.current?.contains(event.target)&&!menu.current?.contains(event.target))setOpen(false);}; - document.addEventListener('pointerdown',click);window.addEventListener('resize',place);window.addEventListener('scroll',place,true); - return()=>{document.removeEventListener('pointerdown',click);window.removeEventListener('resize',place);window.removeEventListener('scroll',place,true);}; - },[open]); - useEffect(()=>{if(open&&position)menu.current?.querySelector('[role="listbox"]')?.focus();},[open,!!position]); - useEffect(()=>{if(open)menu.current?.querySelector(`[data-project-index="${active}"]`)?.scrollIntoView({block:'nearest'});},[active,open]); - return
- {open&&position&&createPortal(
{ - if(event.key==='Escape'){event.preventDefault();close();} - else if(event.target.getAttribute('role')!=='listbox'){return;} - else if(event.key==='ArrowDown'||event.key==='ArrowUp'){event.preventDefault();move(event.key==='ArrowDown'?1:-1);} - else if(event.key==='Enter'||event.key===' '){event.preventDefault();choose(projects[active]);} - else if(event.key==='Tab'){button.current?.focus();setOpen(false);} - }}>
Research projects{onRetry&&}
- {error&&

{error}

} -
{projects.map((project,index)=>)}
- {!projects.length&&!loading&&

No validated recording projects were returned.

}{loading&&

Checking project manifests…

} -

{onAddProject&&}Each project has its own catalog, protocol workspaces and activity.

-
,document.body)} -
; +export default function ProjectNavigator({projects=[],currentProjectUuid,loading=false,disabled=false,error=null,onRetry,onAddProject,onNewProject}){ + const current=currentProject(projects,currentProjectUuid); + return
{current?.name||(loading?'Loading projects…':'Choose a project')}{error&&Project list unavailable{onRetry&&}}
{onAddProject&&}{onNewProject&&}
; } diff --git a/workspace-app/src/projectNavigation.js b/workspace-app/src/projectNavigation.js new file mode 100644 index 0000000..82c63cc --- /dev/null +++ b/workspace-app/src/projectNavigation.js @@ -0,0 +1,12 @@ +export function projectKey(project){return project.path||project.uuid;} +export function orderedProjectRecords(projects,order){ + if(!Array.isArray(order))return projects; + const rank=new Map(order.map((key,index)=>[key,index])); + return projects.map((project,index)=>({project,index})).sort((a,b)=>(rank.get(projectKey(a.project))??order.length+a.index)-(rank.get(projectKey(b.project))??order.length+b.index)).map(item=>item.project); +} +export function projectBootstrap(document=globalThis.document){ + try{ + const data=JSON.parse(document?.getElementById('rieke-projects-bootstrap')?.textContent||'null'); + return data&&Array.isArray(data.projects)&&data.projects.every(project=>typeof project.path==='string'&&typeof project.uuid==='string'&&typeof project.name==='string')?data:null; + }catch{return null;} +} diff --git a/workspace-app/src/projectNavigation.test.js b/workspace-app/src/projectNavigation.test.js new file mode 100644 index 0000000..2086ac6 --- /dev/null +++ b/workspace-app/src/projectNavigation.test.js @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {orderedProjectRecords,projectKey,projectBootstrap} from './projectNavigation.js'; +import {reorderIds} from './ordering.js'; + +test('clicking another current project preserves the folder order, including native copies sharing UUIDs',()=>{ + const projects=[{path:'/a',uuid:'same',current:true},{path:'/b',uuid:'same',current:false},{path:'/c',uuid:'c'}]; + const order=reorderIds(projects.map(projectKey),'/b','/a','before'); + const switched=projects.map(project=>({...project,current:project.path==='/b'})); + assert.deepEqual(orderedProjectRecords(switched,order).map(projectKey),['/b','/a','/c']); + assert.deepEqual(orderedProjectRecords(projects,order).map(projectKey),['/b','/a','/c']); + assert.deepEqual(projects.map(projectKey),['/a','/b','/c']); + assert.deepEqual(orderedProjectRecords([...switched,{path:'/d',uuid:'d'}],order).map(projectKey),['/b','/a','/c','/d']); +}); +test('first render reads the server inventory, with safe fallbacks for missing or malformed bootstrap',()=>{ + const data={current_project_uuid:'a',projects:[{uuid:'a',name:'Alpha',path:'/a'}]}; + const document={getElementById:()=>({textContent:JSON.stringify(data)})}; + assert.deepEqual(projectBootstrap(document),data); + for(const text of ['{broken','null','{"projects":[{}]}'])assert.equal(projectBootstrap({getElementById:()=>({textContent:text})}),null); + assert.equal(projectBootstrap({getElementById:()=>null}),null); +});