@neuralis/brain-core 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -1409
- package/app/README.md +1 -1
- package/app/cards/FilesystemCardShell.tsx +42 -4
- package/app/cards/FilesystemChangeCard.tsx +34 -11
- package/app/cards/README.md +6 -5
- package/app/cards/diffUtils.ts +99 -1
- package/app/filesystem/FilesystemHost.tsx +17 -2
- package/app/filesystem/api/README.md +3 -3
- package/app/filesystem/api/index.ts +35 -11
- package/app/filesystem/api/projectPackages.ts +13 -102
- package/app/filesystem/domain/types.ts +30 -10
- package/app/filesystem/hooks/README.md +3 -3
- package/app/filesystem/hooks/useDiscoverableSources.ts +19 -30
- package/app/filesystem/hooks/useFilesystemSSE.ts +1 -1
- package/app/filesystem/host/README.md +5 -3
- package/app/filesystem/host/buildFilesystemHostValue.ts +19 -0
- package/app/filesystem/host/installFilesystemHostComponents.tsx +1 -1
- package/app/filesystem/lib/sourceKindForm.ts +118 -39
- package/app/filesystem/lib/treeVisualState.tsx +98 -4
- package/app/filesystem/store/README.md +1 -0
- package/app/filesystem/store/filesystemStore.ts +86 -5
- package/app/filesystem/store/gitSlice.ts +5 -0
- package/app/filesystem/store/helpers.ts +17 -0
- package/app/filesystem/store/listSlice.ts +8 -0
- package/app/filesystem/ui/CodeEditor.tsx +18 -3
- package/app/filesystem/ui/FileEditor.tsx +15 -10
- package/app/filesystem/ui/FileTree.tsx +41 -16
- package/app/filesystem/ui/FolderPicker.tsx +251 -0
- package/app/filesystem/ui/GitControlPanel.tsx +5 -0
- package/app/filesystem/ui/PackagesPanel.tsx +52 -374
- package/app/filesystem/ui/PolicyEditor.tsx +109 -24
- package/app/filesystem/ui/README.md +17 -11
- package/app/filesystem/ui/RawMediaPreview.tsx +58 -0
- package/app/filesystem/ui/SourceBranchHeader.tsx +4 -4
- package/app/filesystem/ui/SourcesPanel.tsx +420 -360
- package/app/filesystem/ui/Toolbar.tsx +1 -2
- package/app/shared/filesystem.png +0 -0
- package/dist/app/host.js +57 -49
- package/dist/scripts/sourceRootPairV1.d.ts +47 -0
- package/dist/scripts/sourceRootPairV1.d.ts.map +1 -0
- package/dist/scripts/sourceRootPairV1.js +147 -0
- package/dist/scripts/sourceRootPairV1.js.map +1 -0
- package/dist/scripts/sourceToFolderV1.d.ts +56 -0
- package/dist/scripts/sourceToFolderV1.d.ts.map +1 -0
- package/dist/scripts/sourceToFolderV1.js +152 -0
- package/dist/scripts/sourceToFolderV1.js.map +1 -0
- package/dist/scripts/sourceToHostV1.d.ts +56 -0
- package/dist/scripts/sourceToHostV1.d.ts.map +1 -0
- package/dist/scripts/sourceToHostV1.js +132 -0
- package/dist/scripts/sourceToHostV1.js.map +1 -0
- package/dist/src/connectors/AppSourceConfigStore.d.ts +17 -3
- package/dist/src/connectors/AppSourceConfigStore.d.ts.map +1 -1
- package/dist/src/connectors/AppSourceConfigStore.js +46 -6
- package/dist/src/connectors/AppSourceConfigStore.js.map +1 -1
- package/dist/src/connectors/HostConnector.d.ts +201 -0
- package/dist/src/connectors/HostConnector.d.ts.map +1 -0
- package/dist/src/connectors/HostConnector.js +903 -0
- package/dist/src/connectors/HostConnector.js.map +1 -0
- package/dist/src/connectors/inProcessTransport.d.ts +131 -0
- package/dist/src/connectors/inProcessTransport.d.ts.map +1 -0
- package/dist/src/connectors/inProcessTransport.js +447 -0
- package/dist/src/connectors/inProcessTransport.js.map +1 -0
- package/dist/src/domain/access/callerPermissions.d.ts +29 -0
- package/dist/src/domain/access/callerPermissions.d.ts.map +1 -1
- package/dist/src/domain/access/callerPermissions.js +85 -5
- package/dist/src/domain/access/callerPermissions.js.map +1 -1
- package/dist/src/domain/access/nativeLocalFence.d.ts +65 -0
- package/dist/src/domain/access/nativeLocalFence.d.ts.map +1 -0
- package/dist/src/domain/access/nativeLocalFence.js +171 -0
- package/dist/src/domain/access/nativeLocalFence.js.map +1 -0
- package/dist/src/domain/access/uriPermission.js +3 -3
- package/dist/src/domain/access/uriPermission.js.map +1 -1
- package/dist/src/domain/byteRange.d.ts +15 -0
- package/dist/src/domain/byteRange.d.ts.map +1 -0
- package/dist/src/domain/byteRange.js +30 -0
- package/dist/src/domain/byteRange.js.map +1 -0
- package/dist/src/domain/defaultPermissions.d.ts.map +1 -1
- package/dist/src/domain/defaultPermissions.js +29 -0
- package/dist/src/domain/defaultPermissions.js.map +1 -1
- package/dist/src/domain/mime.d.ts +8 -16
- package/dist/src/domain/mime.d.ts.map +1 -1
- package/dist/src/domain/mime.js +10 -99
- package/dist/src/domain/mime.js.map +1 -1
- package/dist/src/domain/paths.d.ts.map +1 -1
- package/dist/src/domain/paths.js +5 -3
- package/dist/src/domain/paths.js.map +1 -1
- package/dist/src/domain/sourceConfig.d.ts +52 -19
- package/dist/src/domain/sourceConfig.d.ts.map +1 -1
- package/dist/src/domain/sourceConfig.js +103 -19
- package/dist/src/domain/sourceConfig.js.map +1 -1
- package/dist/src/domain/sourcePackages.d.ts +8 -8
- package/dist/src/domain/sourcePackages.d.ts.map +1 -1
- package/dist/src/domain/sourceRootPair.d.ts +88 -0
- package/dist/src/domain/sourceRootPair.d.ts.map +1 -0
- package/dist/src/domain/sourceRootPair.js +108 -0
- package/dist/src/domain/sourceRootPair.js.map +1 -0
- package/dist/src/domain/syncTypes.d.ts +8 -1
- package/dist/src/domain/syncTypes.d.ts.map +1 -1
- package/dist/src/domain/syncTypes.js +1 -0
- package/dist/src/domain/syncTypes.js.map +1 -1
- package/dist/src/domain/types.d.ts +24 -1
- package/dist/src/domain/types.d.ts.map +1 -1
- package/dist/src/domain/uri.d.ts.map +1 -1
- package/dist/src/domain/uri.js +3 -2
- package/dist/src/domain/uri.js.map +1 -1
- package/dist/src/embeddings/clients/GeminiEmbedder.d.ts.map +1 -1
- package/dist/src/embeddings/clients/GeminiEmbedder.js +2 -5
- package/dist/src/embeddings/clients/GeminiEmbedder.js.map +1 -1
- package/dist/src/embeddings/clients/OpenAIEmbedder.d.ts.map +1 -1
- package/dist/src/embeddings/clients/OpenAIEmbedder.js +2 -3
- package/dist/src/embeddings/clients/OpenAIEmbedder.js.map +1 -1
- package/dist/src/embeddings/clients/QwenEmbedder.d.ts +2 -0
- package/dist/src/embeddings/clients/QwenEmbedder.d.ts.map +1 -1
- package/dist/src/embeddings/clients/QwenEmbedder.js +10 -2
- package/dist/src/embeddings/clients/QwenEmbedder.js.map +1 -1
- package/dist/src/embeddings/clients/VoyageEmbedder.d.ts +2 -0
- package/dist/src/embeddings/clients/VoyageEmbedder.d.ts.map +1 -1
- package/dist/src/embeddings/clients/VoyageEmbedder.js +8 -1
- package/dist/src/embeddings/clients/VoyageEmbedder.js.map +1 -1
- package/dist/src/embeddings/factory.d.ts +5 -3
- package/dist/src/embeddings/factory.d.ts.map +1 -1
- package/dist/src/embeddings/factory.js +7 -3
- package/dist/src/embeddings/factory.js.map +1 -1
- package/dist/src/embeddings/models.d.ts +1 -1
- package/dist/src/embeddings/models.d.ts.map +1 -1
- package/dist/src/embeddings/retry.js +1 -1
- package/dist/src/embeddings/retry.js.map +1 -1
- package/dist/src/embeddings/types.d.ts +6 -0
- package/dist/src/embeddings/types.d.ts.map +1 -1
- package/dist/src/embeddings/types.js +11 -0
- package/dist/src/embeddings/types.js.map +1 -1
- package/dist/src/git/GitRunner.d.ts +110 -30
- package/dist/src/git/GitRunner.d.ts.map +1 -1
- package/dist/src/git/GitRunner.js +511 -199
- package/dist/src/git/GitRunner.js.map +1 -1
- package/dist/src/git/GitWatchRegistry.d.ts +41 -35
- package/dist/src/git/GitWatchRegistry.d.ts.map +1 -1
- package/dist/src/git/GitWatchRegistry.js +61 -79
- package/dist/src/git/GitWatchRegistry.js.map +1 -1
- package/dist/src/git/gitPlace.d.ts +61 -0
- package/dist/src/git/gitPlace.d.ts.map +1 -0
- package/dist/src/git/gitPlace.js +74 -0
- package/dist/src/git/gitPlace.js.map +1 -0
- package/dist/src/git/sourceGitControl.d.ts +31 -19
- package/dist/src/git/sourceGitControl.d.ts.map +1 -1
- package/dist/src/git/sourceGitControl.js +208 -92
- package/dist/src/git/sourceGitControl.js.map +1 -1
- package/dist/src/git/sourceGitStatus.js +1 -1
- package/dist/src/git/sourceGitStatus.js.map +1 -1
- package/dist/src/git/untrackedStats.js +1 -1
- package/dist/src/git/untrackedStats.js.map +1 -1
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/lifecycle.d.ts +8 -11
- package/dist/src/lifecycle.d.ts.map +1 -1
- package/dist/src/lifecycle.js +2 -1
- package/dist/src/lifecycle.js.map +1 -1
- package/dist/src/routes/git.d.ts.map +1 -1
- package/dist/src/routes/git.js +183 -54
- package/dist/src/routes/git.js.map +1 -1
- package/dist/src/routes/helpers/uriGuard.d.ts +1 -1
- package/dist/src/routes/helpers/uriGuard.d.ts.map +1 -1
- package/dist/src/routes/helpers/uriGuard.js +3 -2
- package/dist/src/routes/helpers/uriGuard.js.map +1 -1
- package/dist/src/routes/helpers.d.ts +10 -3
- package/dist/src/routes/helpers.d.ts.map +1 -1
- package/dist/src/routes/helpers.js +12 -5
- package/dist/src/routes/helpers.js.map +1 -1
- package/dist/src/routes/list.d.ts.map +1 -1
- package/dist/src/routes/list.js +2 -3
- package/dist/src/routes/list.js.map +1 -1
- package/dist/src/routes/policies.d.ts +6 -4
- package/dist/src/routes/policies.d.ts.map +1 -1
- package/dist/src/routes/policies.js +22 -16
- package/dist/src/routes/policies.js.map +1 -1
- package/dist/src/routes/raw.d.ts.map +1 -1
- package/dist/src/routes/raw.js +65 -11
- package/dist/src/routes/raw.js.map +1 -1
- package/dist/src/routes/sources.d.ts +60 -18
- package/dist/src/routes/sources.d.ts.map +1 -1
- package/dist/src/routes/sources.js +411 -299
- package/dist/src/routes/sources.js.map +1 -1
- package/dist/src/services/BridgeService.d.ts +15 -35
- package/dist/src/services/BridgeService.d.ts.map +1 -1
- package/dist/src/services/BridgeService.js +30 -88
- package/dist/src/services/BridgeService.js.map +1 -1
- package/dist/src/services/FilesystemService.d.ts +3 -2
- package/dist/src/services/FilesystemService.d.ts.map +1 -1
- package/dist/src/services/FilesystemService.js +110 -32
- package/dist/src/services/FilesystemService.js.map +1 -1
- package/dist/src/services/FilesystemUiService.d.ts +4 -1
- package/dist/src/services/FilesystemUiService.d.ts.map +1 -1
- package/dist/src/services/FilesystemUiService.js +66 -25
- package/dist/src/services/FilesystemUiService.js.map +1 -1
- package/dist/src/services/SyncScheduler.d.ts +12 -4
- package/dist/src/services/SyncScheduler.d.ts.map +1 -1
- package/dist/src/services/SyncScheduler.js +37 -8
- package/dist/src/services/SyncScheduler.js.map +1 -1
- package/dist/src/services/baselineRepair.d.ts +13 -3
- package/dist/src/services/baselineRepair.d.ts.map +1 -1
- package/dist/src/services/baselineRepair.js +13 -3
- package/dist/src/services/baselineRepair.js.map +1 -1
- package/dist/src/services/declaredSources.d.ts +125 -42
- package/dist/src/services/declaredSources.d.ts.map +1 -1
- package/dist/src/services/declaredSources.js +117 -45
- package/dist/src/services/declaredSources.js.map +1 -1
- package/dist/src/store/dataFormats.d.ts +17 -2
- package/dist/src/store/dataFormats.d.ts.map +1 -1
- package/dist/src/store/dataFormats.js +18 -3
- package/dist/src/store/dataFormats.js.map +1 -1
- package/dist/src/sync/DeltaSyncRunner.d.ts.map +1 -1
- package/dist/src/sync/DeltaSyncRunner.js +144 -4
- package/dist/src/sync/DeltaSyncRunner.js.map +1 -1
- package/dist/src/sync/SyncJobQueue.d.ts +7 -2
- package/dist/src/sync/SyncJobQueue.d.ts.map +1 -1
- package/dist/src/sync/SyncJobQueue.js +16 -10
- package/dist/src/sync/SyncJobQueue.js.map +1 -1
- package/dist/src/sync/SyncWatchService.d.ts +21 -4
- package/dist/src/sync/SyncWatchService.d.ts.map +1 -1
- package/dist/src/sync/SyncWatchService.js +115 -10
- package/dist/src/sync/SyncWatchService.js.map +1 -1
- package/dist/src/sync/categoryInference.d.ts +1 -1
- package/dist/src/sync/categoryInference.js +1 -1
- package/dist/src/usecases/admitSourceRoot.d.ts +65 -0
- package/dist/src/usecases/admitSourceRoot.d.ts.map +1 -0
- package/dist/src/usecases/admitSourceRoot.js +85 -0
- package/dist/src/usecases/admitSourceRoot.js.map +1 -0
- package/dist/src/usecases/browseSourceFolders.d.ts +85 -0
- package/dist/src/usecases/browseSourceFolders.d.ts.map +1 -0
- package/dist/src/usecases/browseSourceFolders.js +210 -0
- package/dist/src/usecases/browseSourceFolders.js.map +1 -0
- package/dist/src/usecases/createDeclaredSources.d.ts +13 -2
- package/dist/src/usecases/createDeclaredSources.d.ts.map +1 -1
- package/dist/src/usecases/createDeclaredSources.js +39 -0
- package/dist/src/usecases/createDeclaredSources.js.map +1 -1
- package/dist/src/usecases/list.d.ts.map +1 -1
- package/dist/src/usecases/list.js +83 -26
- package/dist/src/usecases/list.js.map +1 -1
- package/dist/src/usecases/listAttachableDevices.d.ts +13 -0
- package/dist/src/usecases/listAttachableDevices.d.ts.map +1 -0
- package/dist/src/usecases/listAttachableDevices.js +10 -0
- package/dist/src/usecases/listAttachableDevices.js.map +1 -0
- package/dist/src/usecases/listSourcePackages.d.ts.map +1 -1
- package/dist/src/usecases/listSourcePackages.js +2 -5
- package/dist/src/usecases/listSourcePackages.js.map +1 -1
- package/dist/src/usecases/purgeExcludedPoints.d.ts.map +1 -1
- package/dist/src/usecases/purgeExcludedPoints.js +3 -2
- package/dist/src/usecases/purgeExcludedPoints.js.map +1 -1
- package/dist/src/usecases/readFiles.d.ts.map +1 -1
- package/dist/src/usecases/readFiles.js +15 -0
- package/dist/src/usecases/readFiles.js.map +1 -1
- package/dist/src/usecases/reconcileSourcePackageRoots.d.ts +1 -1
- package/dist/src/usecases/reconcileSourcePackageRoots.d.ts.map +1 -1
- package/dist/src/usecases/reconcileSourcePackageRoots.js +2 -1
- package/dist/src/usecases/reconcileSourcePackageRoots.js.map +1 -1
- package/dist/src/usecases/revokePrincipal.d.ts +7 -5
- package/dist/src/usecases/revokePrincipal.d.ts.map +1 -1
- package/dist/src/usecases/revokePrincipal.js +29 -7
- package/dist/src/usecases/revokePrincipal.js.map +1 -1
- package/dist/src/usecases/scheduleSourceSync.d.ts +7 -0
- package/dist/src/usecases/scheduleSourceSync.d.ts.map +1 -1
- package/dist/src/usecases/scheduleSourceSync.js +6 -5
- package/dist/src/usecases/scheduleSourceSync.js.map +1 -1
- package/dist/src/usecases/triggerSourceSync.d.ts +46 -0
- package/dist/src/usecases/triggerSourceSync.d.ts.map +1 -0
- package/dist/src/usecases/triggerSourceSync.js +75 -0
- package/dist/src/usecases/triggerSourceSync.js.map +1 -0
- package/dist/src/usecases/uploadBinary.d.ts +2 -1
- package/dist/src/usecases/uploadBinary.d.ts.map +1 -1
- package/dist/src/usecases/uploadBinary.js +2 -2
- package/dist/src/usecases/uploadBinary.js.map +1 -1
- package/dist/src/usecases/vector/endpointGuard.d.ts +1 -1
- package/dist/src/usecases/vector/endpointGuard.js +1 -1
- package/dist/src/vector/qdrantClient.d.ts +1 -1
- package/dist/src/vector/qdrantClient.js +1 -1
- package/dist/src/wiring.d.ts +3 -0
- package/dist/src/wiring.d.ts.map +1 -1
- package/dist/src/wiring.js +72 -6
- package/dist/src/wiring.js.map +1 -1
- package/package.json +145 -54
- package/skills/connect-business-data/SKILL.md +2 -2
- package/skills/create-package/SKILL.md +18 -18
- package/skills/create-package/references/config-and-credentials.md +16 -13
- package/skills/create-package/references/connectors-and-uri-policies.md +36 -27
- package/skills/create-package/references/manifest-and-discovery.md +12 -12
- package/skills/create-package/references/runtime-and-build.md +18 -18
- package/skills/create-package/references/skills-and-context.md +35 -19
- package/skills/create-package/references/surfaces-and-design.md +16 -16
- package/skills/create-package/references/team-and-workflows.md +2 -2
- package/skills/create-package/references/test-and-ship.md +17 -17
- package/skills/create-package/references/tools-and-schemas.md +5 -5
- package/skills/create-package/scripts/{check-package.sh → check-package.mjs} +78 -64
- package/skills/create-package/scripts/{verify-loaded.sh → verify-loaded.mjs} +156 -162
- package/skills/git/SKILL.md +31 -29
- package/skills/manage-files/SKILL.md +11 -14
- package/skills/manage-sources/SKILL.md +85 -45
- package/skills/manage-uri-policy/SKILL.md +9 -6
- package/skills/sync/SKILL.md +1 -1
- package/dist/src/connectors/LocalDiskConnector.d.ts +0 -171
- package/dist/src/connectors/LocalDiskConnector.d.ts.map +0 -1
- package/dist/src/connectors/LocalDiskConnector.js +0 -1233
- package/dist/src/connectors/LocalDiskConnector.js.map +0 -1
- package/skills/manage-files/scripts/copy.sh +0 -7
- package/skills/manage-files/scripts/create.sh +0 -7
- package/skills/manage-files/scripts/mkdir.sh +0 -7
- package/skills/manage-files/scripts/modify.sh +0 -7
- package/skills/manage-files/scripts/upload.sh +0 -16
- package/skills/manage-files/scripts/write.sh +0 -29
- package/skills/manage-sources/scripts/reembed.sh +0 -23
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
Trash2,
|
|
30
30
|
type LucideIcon,
|
|
31
31
|
} from 'lucide-react';
|
|
32
|
-
import { meetsRequires } from '@neuralis/package-system/access';
|
|
32
|
+
import { isOwnerOrAdminEffective, meetsRequires } from '@neuralis/package-system/access';
|
|
33
33
|
import { SkillLauncher, createPackageApi } from '@neuralis/package-system/client';
|
|
34
34
|
import { cn } from '../lib/cn';
|
|
35
35
|
import {
|
|
@@ -46,23 +46,23 @@ import {
|
|
|
46
46
|
getSyncStatus,
|
|
47
47
|
startReembed,
|
|
48
48
|
resetSync,
|
|
49
|
-
browseDirectories,
|
|
50
49
|
listSourceKinds,
|
|
51
50
|
setSourceEnabled,
|
|
52
|
-
listDiscoverableSources,
|
|
53
51
|
fetchSourceCounts,
|
|
52
|
+
browseDirectories,
|
|
54
53
|
type SourceKindRecord,
|
|
55
54
|
type DiscoverableSourceSuggestion,
|
|
56
55
|
} from '../api';
|
|
57
|
-
import { dimmedSourceTone, getSourceVisualTone, sourceLiveReasonLabel } from '../lib/treeVisualState';
|
|
58
|
-
import { fieldControl, rootFieldMeta, selectOptionsFrom, type OptionsRoute, type SelectOption } from '../lib/sourceKindForm';
|
|
56
|
+
import { dimmedSourceTone, getSourceVisualTone, sourceLiveReasonLabel, sourceWatchBadge, syncErrorLabel } from '../lib/treeVisualState';
|
|
57
|
+
import { configFields, fieldControl, kindSummary, rootFieldMeta, selectOptionsFrom, transportChosen, type ConfigField, type OptionsRoute, type SelectOption } from '../lib/sourceKindForm';
|
|
59
58
|
import { resolveIconWithFallback, resolveLucideIcon } from '../lib/iconResolver';
|
|
60
|
-
import { useFilesystemStore } from '../store/filesystemStore';
|
|
59
|
+
import { discoverableKey, useFilesystemStore } from '../store/filesystemStore';
|
|
61
60
|
import { fileKey, sourceFromUri, sourceRootUri } from '../store/helpers';
|
|
62
61
|
import { VectorHealthPanel } from './VectorHealthPanel';
|
|
63
62
|
import { PolicyEditor } from './PolicyEditor';
|
|
64
63
|
import { SourceDeleteConfirm } from './SourceDeleteConfirm';
|
|
65
64
|
import { ReembedDialog } from './ReembedDialog';
|
|
65
|
+
import { DEVICE_FOLDER_NOT_ALLOWED, DeviceFolderAsk, FolderPicker, type FolderSuggestion } from './FolderPicker';
|
|
66
66
|
import { useFilesystemHost } from '../FilesystemHost';
|
|
67
67
|
import type {
|
|
68
68
|
SourceConfigRecord,
|
|
@@ -115,10 +115,38 @@ type AddDraftState = {
|
|
|
115
115
|
* in the Policy editor after saving.
|
|
116
116
|
*/
|
|
117
117
|
policies: PathPermissionRecord[];
|
|
118
|
-
/** R2b —
|
|
119
|
-
|
|
118
|
+
/** R2b — the folder's package folders, one per line (`.` = the whole folder; owner/admin). */
|
|
119
|
+
packageFolders: string;
|
|
120
|
+
/** The chosen device's claimed platform (its WHERE option's), for the folder hint; never submitted. */
|
|
121
|
+
transportPlatform: string;
|
|
120
122
|
};
|
|
121
123
|
|
|
124
|
+
/** No suggestions held for the current pair — one stable reference, so a selector never churns. */
|
|
125
|
+
const NO_SUGGESTIONS: readonly DiscoverableSourceSuggestion[] = [];
|
|
126
|
+
|
|
127
|
+
/** The ONE folder kind (brain-core's connector; the stored `device` picks the transport). */
|
|
128
|
+
const HOST_KIND = 'host';
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `packageFolders` ⇄ the one-per-line field both forms show: `.` (or `/`) is the
|
|
132
|
+
* whole folder (`''`), every other line a folder relative to the source root,
|
|
133
|
+
* surrounding slashes dropped. The server validates what this produces.
|
|
134
|
+
*/
|
|
135
|
+
function parsePackageFolders(text: string): string[] {
|
|
136
|
+
const out: string[] = [];
|
|
137
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
138
|
+
const line = raw.trim();
|
|
139
|
+
if (!line) continue;
|
|
140
|
+
const folder = line === '.' || line === '/' ? '' : line.replace(/^\/+|\/+$/g, '');
|
|
141
|
+
if (!out.includes(folder)) out.push(folder);
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function formatPackageFolders(folders: readonly string[] | undefined): string {
|
|
147
|
+
return (folders ?? []).map((folder) => (folder === '' ? '.' : folder)).join('\n');
|
|
148
|
+
}
|
|
149
|
+
|
|
122
150
|
const DEFAULT_SYNC: NonNullable<UpdateSourceConfigRequest['sync']> = {
|
|
123
151
|
exclude: [],
|
|
124
152
|
trigger: 'manual',
|
|
@@ -210,8 +238,14 @@ function suggestedSourceKey(params: {
|
|
|
210
238
|
return `${base}-${ids.agentSlug || shortToken(ids.agentId)}`;
|
|
211
239
|
}
|
|
212
240
|
|
|
213
|
-
|
|
214
|
-
|
|
241
|
+
/**
|
|
242
|
+
* The scopes the form offers. A DEVICE folder (a transport chosen) is the
|
|
243
|
+
* attaching owner's user or the project's — never an agent's — mirroring the
|
|
244
|
+
* server's refusal, so the form never offers what the create would 400.
|
|
245
|
+
*/
|
|
246
|
+
function scopeOptions(meta: SourceKindRecord | null, config: Readonly<Record<string, unknown>> = {}): SourceScopeKind[] {
|
|
247
|
+
const all: SourceScopeKind[] = meta?.allowedScopes?.length ? meta.allowedScopes : ['project', 'user', 'agent'];
|
|
248
|
+
return transportChosen(meta, config) ? all.filter((scope) => scope !== 'agent') : all;
|
|
215
249
|
}
|
|
216
250
|
|
|
217
251
|
function supportsSync(meta: SourceKindRecord | null): boolean {
|
|
@@ -246,8 +280,13 @@ function defaultAccess(meta: SourceKindRecord | null): { read: boolean; write: b
|
|
|
246
280
|
};
|
|
247
281
|
}
|
|
248
282
|
|
|
249
|
-
|
|
250
|
-
|
|
283
|
+
/**
|
|
284
|
+
* A fresh add-form draft for `meta`. `scopeHint` is the Sources list tab the
|
|
285
|
+
* person is on: the new source starts there when the kind allows that scope,
|
|
286
|
+
* otherwise at the kind's own default.
|
|
287
|
+
*/
|
|
288
|
+
function buildAddDraft(meta: SourceKindRecord | null, ids: SourceKeyIds, scopeHint?: SourceScopeKind): AddDraftState {
|
|
289
|
+
const scope = scopeHint && meta?.scopeSelectable && scopeOptions(meta).includes(scopeHint) ? scopeHint : meta?.defaultScope ?? 'project';
|
|
251
290
|
const access = defaultAccess(meta);
|
|
252
291
|
return {
|
|
253
292
|
// No folder picked yet → base falls back to the kind scheme.
|
|
@@ -262,7 +301,8 @@ function buildAddDraft(meta: SourceKindRecord | null, ids: SourceKeyIds): AddDra
|
|
|
262
301
|
description: '',
|
|
263
302
|
config: defaultConfig(meta),
|
|
264
303
|
policies: [],
|
|
265
|
-
|
|
304
|
+
packageFolders: '',
|
|
305
|
+
transportPlatform: '',
|
|
266
306
|
};
|
|
267
307
|
}
|
|
268
308
|
|
|
@@ -286,8 +326,8 @@ function buildDraft(c: SourceConfigRecord): UpdateSourceConfigRequest {
|
|
|
286
326
|
source: c.source,
|
|
287
327
|
permissions: normalizePerms(c.permissions),
|
|
288
328
|
description: c.description ?? '',
|
|
289
|
-
// R2b — include in the draft so the
|
|
290
|
-
|
|
329
|
+
// R2b — include in the draft so the list participates in isDirty + PATCH.
|
|
330
|
+
packageFolders: [...(c.packageFolders ?? [])],
|
|
291
331
|
...(c.sync ? {
|
|
292
332
|
sync: {
|
|
293
333
|
exclude: [...(c.sync.exclude ?? [])],
|
|
@@ -358,7 +398,7 @@ export function binarySkipLine(count: number | undefined): string | null {
|
|
|
358
398
|
* source's own exclude/include globs and its scan cap (`usecases/list.ts`
|
|
359
399
|
* `computeSourcesRegistry` pushes them into `connector.count()`), so editing
|
|
360
400
|
* them moves the count immediately. Everything else a save can carry —
|
|
361
|
-
* description, trigger cadence, `
|
|
401
|
+
* description, trigger cadence, `packageFolders`, the whole permissions
|
|
362
402
|
* tree — leaves both numbers where they were: the indexed count is a
|
|
363
403
|
* project-scoped `countByFilter` with no per-caller policy input, and the
|
|
364
404
|
* on-disk count is physical truth.
|
|
@@ -403,8 +443,12 @@ function triggerLabel(trigger: string): string {
|
|
|
403
443
|
}
|
|
404
444
|
|
|
405
445
|
function describeConn(c: SourceConfigRecord, meta: SourceKindRecord | null): string {
|
|
446
|
+
if (c.root_effective) return c.root_effective;
|
|
406
447
|
const cfg = (c.connection.config ?? {}) as Record<string, unknown>;
|
|
407
|
-
//
|
|
448
|
+
// A folder kind names its folder: the form stores the transport (`device`)
|
|
449
|
+
// first, so "the first value" would show a device id where the path belongs.
|
|
450
|
+
if (typeof cfg.root === 'string' && cfg.root.length > 0) return cfg.root;
|
|
451
|
+
// Otherwise a schema-hint-driven summary: show the first string/number value.
|
|
408
452
|
for (const [key, value] of Object.entries(cfg)) {
|
|
409
453
|
if (typeof value === 'string' && value.length > 0) return value;
|
|
410
454
|
if (typeof value === 'number') return String(value);
|
|
@@ -412,6 +456,12 @@ function describeConn(c: SourceConfigRecord, meta: SourceKindRecord | null): str
|
|
|
412
456
|
return meta?.label ?? c.connection.kind;
|
|
413
457
|
}
|
|
414
458
|
|
|
459
|
+
/** A source this server opens through its container half (`root_effective` is the stored `root`) that has a machine half. */
|
|
460
|
+
function isDockerBind(c: SourceConfigRecord): boolean {
|
|
461
|
+
const { root, hostRoot } = (c.connection?.config ?? {}) as { root?: unknown; hostRoot?: unknown };
|
|
462
|
+
return Boolean(hostRoot) && (c.root_effective === undefined || c.root_effective === root);
|
|
463
|
+
}
|
|
464
|
+
|
|
415
465
|
function sourceSummaryToObservedConfig(
|
|
416
466
|
summary: SourceInfo,
|
|
417
467
|
meta: SourceKindRecord | null,
|
|
@@ -435,6 +485,7 @@ function sourceSummaryToObservedConfig(
|
|
|
435
485
|
...(summary.origin_package ? { origin_package: summary.origin_package } : {}),
|
|
436
486
|
...(summary.live !== undefined ? { live: summary.live } : {}),
|
|
437
487
|
...(summary.live_reason ? { live_reason: summary.live_reason } : {}),
|
|
488
|
+
...(summary.live_not_ready ? { live_not_ready: summary.live_not_ready } : {}),
|
|
438
489
|
};
|
|
439
490
|
}
|
|
440
491
|
|
|
@@ -487,10 +538,13 @@ export function SourcesPanel(props: Props) {
|
|
|
487
538
|
const host = useFilesystemHost();
|
|
488
539
|
const agentsState = host.useAgents();
|
|
489
540
|
const userSlug = useMemo(() => deriveUserSlug(host.currentUser), [host.currentUser]);
|
|
490
|
-
// R2b —
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
const isOwnerEffective = (
|
|
541
|
+
// R2b — the package-folders field shows for exactly the callers the server's
|
|
542
|
+
// gate admits: the SAME kernel predicate (`isOwnerOrAdminEffective`), so the
|
|
543
|
+
// hide cannot drift from the lock. The server stays the floor; no signal ⇒ hide.
|
|
544
|
+
const isOwnerEffective = isOwnerOrAdminEffective({
|
|
545
|
+
priority: host.currentRole?.priority,
|
|
546
|
+
grantedFeatures: host.grantedFeatures,
|
|
547
|
+
});
|
|
494
548
|
// warded-wren — affordance mirror for the `sources.mount` gate that now covers
|
|
495
549
|
// browse and the discoverable list. Same shape as `FileTree`'s: the ONE shared
|
|
496
550
|
// kernel predicate, a copy at the boundary because the host keeps the grant
|
|
@@ -563,18 +617,18 @@ export function SourcesPanel(props: Props) {
|
|
|
563
617
|
const healthOpen = useFilesystemStore((s) => s.vectorHealthOpen);
|
|
564
618
|
const setVectorHealthOpen = useFilesystemStore((s) => s.setVectorHealthOpen);
|
|
565
619
|
const [mountOpen, setMountOpen] = useState(false);
|
|
566
|
-
|
|
567
|
-
const [
|
|
568
|
-
|
|
569
|
-
const [
|
|
570
|
-
const [browseError, setBrowseError] = useState<string | null>(null);
|
|
571
|
-
const [dockerMounts, setDockerMounts] = useState<Array<{ name: string; path: string }>>([]);
|
|
572
|
-
const [dockerMountsLoading, setDockerMountsLoading] = useState(false);
|
|
620
|
+
/** The folder picker under the Folder field (`FolderPicker` owns its listing state). */
|
|
621
|
+
const [pickerOpen, setPickerOpen] = useState(false);
|
|
622
|
+
/** The device folder a Create found outside that device's allowed folders: ask, or attach anyway. */
|
|
623
|
+
const [deviceGuard, setDeviceGuard] = useState<string | null>(null);
|
|
573
624
|
const [kinds, setKinds] = useState<SourceKindRecord[]>([]);
|
|
574
|
-
const [addKind, setAddKind] = useState<string>(
|
|
625
|
+
const [addKind, setAddKind] = useState<string>(HOST_KIND);
|
|
575
626
|
const [kindsError, setKindsError] = useState<string | null>(null);
|
|
576
|
-
// W3E — discoverable (well-known but unattached) source roots
|
|
577
|
-
|
|
627
|
+
// W3E — discoverable (well-known but unattached) source roots: the window
|
|
628
|
+
// store's ONE copy, shared with the tree's Discover dock (`loadDiscoverable`).
|
|
629
|
+
const loadDiscoverable = useFilesystemStore((s) => s.loadDiscoverable);
|
|
630
|
+
const discoverableKeyNow = discoverableKey(agentId, projectId ?? undefined);
|
|
631
|
+
const discoverable = useFilesystemStore((s) => (s.discoverable?.key === discoverableKeyNow ? s.discoverable.suggestions : NO_SUGGESTIONS));
|
|
578
632
|
|
|
579
633
|
// Load registered source kinds once per mount — this is what powers the
|
|
580
634
|
// schema-driven picker (replacing the old hardcoded KIND_META).
|
|
@@ -585,10 +639,10 @@ export function SourcesPanel(props: Props) {
|
|
|
585
639
|
if (cancelled) return;
|
|
586
640
|
setKinds(res.kinds ?? []);
|
|
587
641
|
setKindsError(null);
|
|
588
|
-
const preferred = res.kinds?.find((k) => k.kind ===
|
|
642
|
+
const preferred = res.kinds?.find((k) => k.kind === HOST_KIND) ?? res.kinds?.[0] ?? null;
|
|
589
643
|
if (preferred) {
|
|
590
644
|
setAddKind(preferred.kind);
|
|
591
|
-
const draft = buildAddDraft(preferred, sourceKeyIds);
|
|
645
|
+
const draft = buildAddDraft(preferred, sourceKeyIds, activeScope);
|
|
592
646
|
autoKeyRef.current = draft.key;
|
|
593
647
|
setAddDraft(draft);
|
|
594
648
|
}
|
|
@@ -607,13 +661,22 @@ export function SourcesPanel(props: Props) {
|
|
|
607
661
|
}, [kinds]);
|
|
608
662
|
const selectedKind = kindByName.get(addKind) ?? null;
|
|
609
663
|
const selectedKindSupportsSync = supportsSync(selectedKind);
|
|
610
|
-
const rootField = rootFieldMeta(selectedKind);
|
|
664
|
+
const rootField = rootFieldMeta(selectedKind, addDraft.config, addDraft.transportPlatform);
|
|
611
665
|
const selectedKindNeedsPath = rootField.needed;
|
|
612
|
-
// The
|
|
613
|
-
//
|
|
614
|
-
|
|
615
|
-
// device
|
|
616
|
-
const
|
|
666
|
+
// The non-root fields in the one-page order: WHERE first (it decides what the
|
|
667
|
+
// folder means), the plain fields, the advanced ones folded.
|
|
668
|
+
const formFields = configFields(selectedKind, addDraft.config);
|
|
669
|
+
// The device the folder lives on — the transport field's value — or `null` for this server.
|
|
670
|
+
const chosenDeviceValue = formFields.transport[0] ? addDraft.config[formFields.transport[0].key] : undefined;
|
|
671
|
+
const chosenDevice = typeof chosenDeviceValue === 'string' && chosenDeviceValue ? chosenDeviceValue : null;
|
|
672
|
+
// The picker's start on this server: the discoverable folders of the host kind
|
|
673
|
+
// (already loaded with the list — no request of its own).
|
|
674
|
+
const folderSuggestions = useMemo<FolderSuggestion[]>(
|
|
675
|
+
() => discoverable
|
|
676
|
+
.filter((s) => s.suggestedKind === HOST_KIND && typeof s.suggestedConfig.root === 'string')
|
|
677
|
+
.map((s) => ({ name: s.label, path: s.suggestedConfig.root as string })),
|
|
678
|
+
[discoverable],
|
|
679
|
+
);
|
|
617
680
|
|
|
618
681
|
/**
|
|
619
682
|
* Open the Add-Source form prefilled from a discoverable suggestion.
|
|
@@ -696,33 +759,34 @@ export function SourcesPanel(props: Props) {
|
|
|
696
759
|
/** Whether the key field still holds an auto-suggestion (safe to re-suggest). */
|
|
697
760
|
const keyIsUntouched = (key: string) => key === '' || key === autoKeyRef.current;
|
|
698
761
|
|
|
699
|
-
const load = useCallback(async () => {
|
|
762
|
+
const load = useCallback(async (fresh = true) => {
|
|
700
763
|
if (!projectId) return; // Don't fetch without projectId — it would query the wrong directory
|
|
701
764
|
setLoading(true);
|
|
702
765
|
setError(null);
|
|
703
766
|
try {
|
|
704
|
-
// W3E — load source configs and discoverable suggestions in parallel.
|
|
705
|
-
|
|
767
|
+
// W3E — load source configs and discoverable suggestions in parallel. The
|
|
768
|
+
// suggestions go through the store's one owner: on open an answer the
|
|
769
|
+
// tree's dock already holds (or is fetching) is reused; every other call
|
|
770
|
+
// (Refresh, after a mutation) re-reads, so an attached root drops out.
|
|
771
|
+
const [next] = await Promise.all([
|
|
706
772
|
listSourceConfigs({
|
|
707
773
|
agentId,
|
|
708
774
|
projectId,
|
|
709
775
|
scope: activeScope,
|
|
710
776
|
}, client),
|
|
711
|
-
|
|
712
|
-
.catch(() => ({ suggestions: [] as DiscoverableSourceSuggestion[] })),
|
|
777
|
+
loadDiscoverable({ agentId, projectId }, { fresh }),
|
|
713
778
|
]);
|
|
714
779
|
setConfigs(next);
|
|
715
780
|
setDrafts(Object.fromEntries(next.map((c) => [c.source, buildDraft(c)])));
|
|
716
781
|
setExpanded((prev) => Object.fromEntries(next.map((c) => [c.source, prev[c.source] ?? false])));
|
|
717
|
-
setDiscoverable(discoverableRes.suggestions ?? []);
|
|
718
782
|
} catch (err) {
|
|
719
783
|
setError(err instanceof Error ? err.message : 'Failed to load sources');
|
|
720
784
|
} finally {
|
|
721
785
|
setLoading(false);
|
|
722
786
|
}
|
|
723
|
-
}, [activeScope, agentId, projectId]);
|
|
787
|
+
}, [activeScope, agentId, projectId, loadDiscoverable]);
|
|
724
788
|
|
|
725
|
-
useEffect(() => { if (open) void load(); }, [open, load]);
|
|
789
|
+
useEffect(() => { if (open) void load(false); }, [open, load]);
|
|
726
790
|
|
|
727
791
|
// On panel open, do a COUNT-FREE refresh: the connector-first list merge still
|
|
728
792
|
// populates `sourceSummaries` (`s.sources`) with source discovery (observed-only
|
|
@@ -792,21 +856,6 @@ export function SourcesPanel(props: Props) {
|
|
|
792
856
|
return () => clearTimeout(t);
|
|
793
857
|
}, [successMsg]);
|
|
794
858
|
|
|
795
|
-
// Docker mode: load available volume mounts when +Source form opens
|
|
796
|
-
useEffect(() => {
|
|
797
|
-
if (!showAddForm || !isDocker || !canMount) return;
|
|
798
|
-
setDockerMountsLoading(true);
|
|
799
|
-
browseDirectories({ path: '/mounts', agentId, ...(projectId ? { projectId } : {}) }, client)
|
|
800
|
-
.then((result) => {
|
|
801
|
-
// Filter out volumes already configured as sources
|
|
802
|
-
const configuredSources = new Set(configs.map((c) => c.source));
|
|
803
|
-
const unmounted = result.entries.filter((e) => !configuredSources.has(e.name));
|
|
804
|
-
setDockerMounts(unmounted);
|
|
805
|
-
})
|
|
806
|
-
.catch(() => setDockerMounts([]))
|
|
807
|
-
.finally(() => setDockerMountsLoading(false));
|
|
808
|
-
}, [showAddForm, isDocker, canMount, agentId, projectId, configs]);
|
|
809
|
-
|
|
810
859
|
const merged = useMemo(() => {
|
|
811
860
|
const seen = new Set<string>();
|
|
812
861
|
const rows: Array<SourceConfigRecord & {
|
|
@@ -835,6 +884,8 @@ export function SourcesPanel(props: Props) {
|
|
|
835
884
|
// the row icon + name when the connector can't reach its backing store.
|
|
836
885
|
live: s?.live,
|
|
837
886
|
live_reason: s?.live_reason,
|
|
887
|
+
live_not_ready: s?.live_not_ready,
|
|
888
|
+
watch: s?.watch,
|
|
838
889
|
kind_icon: s?.kind_icon,
|
|
839
890
|
};
|
|
840
891
|
});
|
|
@@ -842,7 +893,9 @@ export function SourcesPanel(props: Props) {
|
|
|
842
893
|
if (activeScope === 'project') {
|
|
843
894
|
for (const summary of sourceSummaries) {
|
|
844
895
|
const source = sourceFromUri(summary.uri_root);
|
|
845
|
-
|
|
896
|
+
// A source configured at the user or agent scope is edited on its own
|
|
897
|
+
// tab — only one with no config at all is runtime-observed here.
|
|
898
|
+
if (!source || seen.has(source) || (summary.config_scope !== undefined && summary.config_scope !== 'project')) continue;
|
|
846
899
|
const meta = kindByName.get(summary.source_kind) ?? null;
|
|
847
900
|
const counts = countsBySource[source] ?? summary;
|
|
848
901
|
rows.push({
|
|
@@ -897,13 +950,13 @@ export function SourcesPanel(props: Props) {
|
|
|
897
950
|
// "no change" server-side and would preserve the old config. Send an
|
|
898
951
|
// explicit clear flag when the source HAD sync and the draft turned it off.
|
|
899
952
|
const clearSync = !draft.sync && !!config.sync;
|
|
900
|
-
// R2b — `
|
|
901
|
-
// server. `buildDraft` always carries it, so STRIP it for a non-owner
|
|
902
|
-
//
|
|
953
|
+
// R2b — `packageFolders` is owner/admin-effective + folder-kind-only on
|
|
954
|
+
// the server. `buildDraft` always carries it, so STRIP it for a non-owner
|
|
955
|
+
// (or another kind) to avoid tripping the server's owner-only gate on an
|
|
903
956
|
// unrelated description/sync edit. Owner-effective callers send the real value.
|
|
904
|
-
const
|
|
905
|
-
const {
|
|
906
|
-
const payload =
|
|
957
|
+
const canEditPackageFolders = isOwnerEffective && config.connection.kind === HOST_KIND;
|
|
958
|
+
const { packageFolders: _pf, ...draftRest } = draft;
|
|
959
|
+
const payload = canEditPackageFolders ? draft : draftRest;
|
|
907
960
|
const updated = await updateSourceConfig({
|
|
908
961
|
agentId,
|
|
909
962
|
...(projectId ? { projectId } : {}),
|
|
@@ -1004,17 +1057,18 @@ export function SourcesPanel(props: Props) {
|
|
|
1004
1057
|
// the same time. Keep every hook above the guard.
|
|
1005
1058
|
if (!open) return null;
|
|
1006
1059
|
|
|
1007
|
-
const handleSync = async (source: string) => {
|
|
1060
|
+
const handleSync = async (source: string, confirmEmpty = false) => {
|
|
1008
1061
|
setSyncingSource(source);
|
|
1009
1062
|
setError(null);
|
|
1010
1063
|
try {
|
|
1011
|
-
await triggerSourceSync({ agentId, ...(projectId ? { projectId } : {}), source }, client);
|
|
1064
|
+
await triggerSourceSync({ agentId, ...(projectId ? { projectId } : {}), source, ...(confirmEmpty ? { confirmEmpty } : {}) }, client);
|
|
1012
1065
|
await pollSyncJob(source);
|
|
1013
1066
|
await load();
|
|
1014
1067
|
await refresh({ agentId, projectId, withCounts: true });
|
|
1015
1068
|
clearCachedCounts(source);
|
|
1016
1069
|
} catch (err) {
|
|
1017
|
-
|
|
1070
|
+
await load(); // the row's last-sync error (and its Sync anyway) comes from the reloaded config
|
|
1071
|
+
setError(err instanceof Error ? syncErrorLabel(err.message) : 'Sync failed');
|
|
1018
1072
|
} finally {
|
|
1019
1073
|
setSyncingSource(null);
|
|
1020
1074
|
}
|
|
@@ -1149,25 +1203,7 @@ export function SourcesPanel(props: Props) {
|
|
|
1149
1203
|
}
|
|
1150
1204
|
};
|
|
1151
1205
|
|
|
1152
|
-
const
|
|
1153
|
-
setBrowseOpen(true);
|
|
1154
|
-
setBrowseLoading(true);
|
|
1155
|
-
setBrowseError(null);
|
|
1156
|
-
try {
|
|
1157
|
-
const result = await browseDirectories({ path, agentId, ...(projectId ? { projectId } : {}) }, client);
|
|
1158
|
-
setBrowseEntries(result.entries);
|
|
1159
|
-
setBrowsePath(path);
|
|
1160
|
-
} catch {
|
|
1161
|
-
// One stable sentence, matching the route: the server never returns the
|
|
1162
|
-
// path or the errno, so there is nothing here worth surfacing verbatim.
|
|
1163
|
-
setBrowseError('That path is not available.');
|
|
1164
|
-
setBrowseEntries([]);
|
|
1165
|
-
} finally {
|
|
1166
|
-
setBrowseLoading(false);
|
|
1167
|
-
}
|
|
1168
|
-
};
|
|
1169
|
-
|
|
1170
|
-
const selectBrowsePath = (path: string) => {
|
|
1206
|
+
const selectPickedFolder = (path: string) => {
|
|
1171
1207
|
setAddDraft((d) => {
|
|
1172
1208
|
// Re-suggest the key from the picked folder's name — but only while the
|
|
1173
1209
|
// key is still an auto-suggestion. A manually-typed key is preserved.
|
|
@@ -1176,18 +1212,88 @@ export function SourcesPanel(props: Props) {
|
|
|
1176
1212
|
autoKeyRef.current = key;
|
|
1177
1213
|
return { ...d, path, key };
|
|
1178
1214
|
});
|
|
1179
|
-
|
|
1215
|
+
setPickerOpen(false);
|
|
1180
1216
|
};
|
|
1181
1217
|
|
|
1182
|
-
|
|
1218
|
+
/** One non-root config field (WHERE, a plain field, an advanced one) — its control from the declaration. */
|
|
1219
|
+
const renderConfigField = ({ key, prop, label, help, placeholder }: ConfigField) => {
|
|
1220
|
+
const field = fieldControl(prop, selectedKind?.packageId ?? '');
|
|
1221
|
+
const isTransport = formFields.transport.some((f) => f.key === key);
|
|
1222
|
+
const setValue = (value: string, option?: SelectOption) => {
|
|
1223
|
+
setAddDraft((prev) => {
|
|
1224
|
+
const d = isTransport ? { ...prev, transportPlatform: option?.platform ?? '' } : prev;
|
|
1225
|
+
const config = { ...d.config, [key]: value };
|
|
1226
|
+
const before = transportChosen(selectedKind, d.config);
|
|
1227
|
+
const after = transportChosen(selectedKind, config);
|
|
1228
|
+
if (before === after) return { ...d, config };
|
|
1229
|
+
// Choosing a device makes the folder its owner's own by default
|
|
1230
|
+
// (the server's default too): user scope, never agent, and
|
|
1231
|
+
// nothing granted until the owner widens it — a device folder
|
|
1232
|
+
// at project scope would otherwise open somebody's machine to
|
|
1233
|
+
// every member the moment it exists. Back to this server: the
|
|
1234
|
+
// kind's own defaults.
|
|
1235
|
+
return after
|
|
1236
|
+
? { ...d, config, scope: 'user' as SourceScopeKind, read: false, write: false, exec: false }
|
|
1237
|
+
: { ...d, config, scope: selectedKind?.defaultScope ?? 'project', ...defaultAccess(selectedKind) };
|
|
1238
|
+
});
|
|
1239
|
+
// A different machine: the open picker listed the other one.
|
|
1240
|
+
if (isTransport) setPickerOpen(false);
|
|
1241
|
+
};
|
|
1242
|
+
return (
|
|
1243
|
+
<div key={key} className="space-y-1">
|
|
1244
|
+
<div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">{label}</div>
|
|
1245
|
+
{field.control === 'select' ? (
|
|
1246
|
+
<OptionsRouteSelect
|
|
1247
|
+
route={field.options}
|
|
1248
|
+
{...(field.emptyLabel ? { emptyLabel: field.emptyLabel } : {})}
|
|
1249
|
+
revision={deviceRevision}
|
|
1250
|
+
projectId={projectId}
|
|
1251
|
+
agentId={agentId}
|
|
1252
|
+
value={String(addDraft.config[key] ?? '')}
|
|
1253
|
+
onChange={setValue}
|
|
1254
|
+
/>
|
|
1255
|
+
) : (
|
|
1256
|
+
<input
|
|
1257
|
+
type={field.numeric ? 'number' : 'text'}
|
|
1258
|
+
value={String(addDraft.config[key] ?? '')}
|
|
1259
|
+
onChange={(e) => setValue(e.target.value)}
|
|
1260
|
+
placeholder={placeholder ?? key}
|
|
1261
|
+
className={inputCls}
|
|
1262
|
+
/>
|
|
1263
|
+
)}
|
|
1264
|
+
{help ? <div className="text-[8px] text-white/30 italic">{help}</div> : null}
|
|
1265
|
+
{field.control === 'text' && field.refused ? (
|
|
1266
|
+
<div className="text-[9px] text-amber-300/80">{field.refused}</div>
|
|
1267
|
+
) : null}
|
|
1268
|
+
</div>
|
|
1269
|
+
);
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
const handleAddSource = async (attachAnyway = false) => {
|
|
1183
1273
|
if (!addDraft.key || (selectedKindNeedsPath && !addDraft.path)) return;
|
|
1184
1274
|
setAddCreating(true);
|
|
1185
1275
|
setError(null);
|
|
1186
1276
|
try {
|
|
1277
|
+
// A folder outside what the device allows would attach and then answer
|
|
1278
|
+
// `host_fs_denied` on every read: say so BEFORE creating and offer the
|
|
1279
|
+
// ask — attaching first and granting later stays a choice. One browse of
|
|
1280
|
+
// that folder per Create (the device's clamp answers `ceiling_denied`
|
|
1281
|
+
// whether or not it exists); any other answer creates as before.
|
|
1282
|
+
if (chosenDevice && selectedKindNeedsPath && !attachAnyway) {
|
|
1283
|
+
const verdict = await browseDirectories({ path: addDraft.path, agentId, ...(projectId ? { projectId } : {}), device: chosenDevice }, client).catch(() => null);
|
|
1284
|
+
if (verdict?.state === 'ceiling_denied') {
|
|
1285
|
+
setDeviceGuard(addDraft.path);
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
setDeviceGuard(null);
|
|
1290
|
+
// An optional field left empty is not sent: an empty `device` would be a
|
|
1291
|
+
// malformed device id, not "this server".
|
|
1187
1292
|
const config = {
|
|
1188
|
-
...addDraft.config,
|
|
1293
|
+
...Object.fromEntries(Object.entries(addDraft.config).filter(([, value]) => value !== '')),
|
|
1189
1294
|
...(selectedKindNeedsPath ? { root: addDraft.path } : {}),
|
|
1190
1295
|
};
|
|
1296
|
+
const packageFolders = parsePackageFolders(addDraft.packageFolders);
|
|
1191
1297
|
await createSource({
|
|
1192
1298
|
agentId,
|
|
1193
1299
|
...(projectId ? { projectId } : {}),
|
|
@@ -1210,15 +1316,16 @@ export function SourcesPanel(props: Props) {
|
|
|
1210
1316
|
...(linesToList(addDraft.include).length > 0 ? { include: linesToList(addDraft.include) } : {}),
|
|
1211
1317
|
},
|
|
1212
1318
|
} : {}),
|
|
1213
|
-
// R2b — only send when an owner/admin-effective caller
|
|
1214
|
-
//
|
|
1215
|
-
...(isOwnerEffective && addKind ===
|
|
1216
|
-
? {
|
|
1319
|
+
// R2b — only send when an owner/admin-effective caller listed package
|
|
1320
|
+
// folders. The server re-gates (owner/admin + folder kind only).
|
|
1321
|
+
...(isOwnerEffective && addKind === HOST_KIND && packageFolders.length > 0
|
|
1322
|
+
? { packageFolders }
|
|
1217
1323
|
: {}),
|
|
1218
1324
|
}, client);
|
|
1219
1325
|
setShowAddForm(false);
|
|
1326
|
+
setPickerOpen(false);
|
|
1220
1327
|
{
|
|
1221
|
-
const reset = buildAddDraft(selectedKind, sourceKeyIds);
|
|
1328
|
+
const reset = buildAddDraft(selectedKind, sourceKeyIds, activeScope);
|
|
1222
1329
|
autoKeyRef.current = reset.key;
|
|
1223
1330
|
setAddDraft(reset);
|
|
1224
1331
|
}
|
|
@@ -1251,7 +1358,16 @@ export function SourcesPanel(props: Props) {
|
|
|
1251
1358
|
<div className="inline-flex items-center gap-0.5">
|
|
1252
1359
|
<button
|
|
1253
1360
|
type="button"
|
|
1254
|
-
onClick={() =>
|
|
1361
|
+
onClick={() => {
|
|
1362
|
+
// A new source starts on the Sources tab the person is looking at
|
|
1363
|
+
// (when the kind allows that scope and the draft is untouched).
|
|
1364
|
+
if (!showAddForm && keyIsUntouched(addDraft.key) && !addDraft.path) {
|
|
1365
|
+
const draft = buildAddDraft(selectedKind, sourceKeyIds, activeScope);
|
|
1366
|
+
autoKeyRef.current = draft.key;
|
|
1367
|
+
setAddDraft(draft);
|
|
1368
|
+
}
|
|
1369
|
+
setShowAddForm((v) => !v);
|
|
1370
|
+
}}
|
|
1255
1371
|
className="h-7 px-2 rounded-l-md rounded-r-none text-cyan-300 hover:text-cyan-200 hover:bg-white/[0.05] transition inline-flex items-center gap-1 text-[10px] font-medium"
|
|
1256
1372
|
title="Add source"
|
|
1257
1373
|
>
|
|
@@ -1287,7 +1403,7 @@ export function SourcesPanel(props: Props) {
|
|
|
1287
1403
|
|
|
1288
1404
|
{/* Description */}
|
|
1289
1405
|
<div className="mt-2 text-[10px] text-white/35 italic">
|
|
1290
|
-
Add filesystem-style sources from Neuralis packages. A source URI such as <code className="font-mono">neuralis://</code> or <code className="font-mono"
|
|
1406
|
+
Add filesystem-style sources from Neuralis packages. A source URI such as <code className="font-mono">neuralis://</code> or <code className="font-mono"><machine-source-slug>://</code> is the stable handle agents use with fs_* tools.
|
|
1291
1407
|
</div>
|
|
1292
1408
|
|
|
1293
1409
|
<div className="mt-2 flex items-center gap-1 rounded-md bg-white/[0.025] p-1">
|
|
@@ -1316,13 +1432,14 @@ export function SourcesPanel(props: Props) {
|
|
|
1316
1432
|
<div className="mt-2 rounded-md bg-red-500/8 px-3 py-2 text-[10px] text-red-200/80">{error}</div>
|
|
1317
1433
|
)}
|
|
1318
1434
|
|
|
1319
|
-
{/* Source wizard (inline)
|
|
1435
|
+
{/* Source wizard (inline) — one page, in the order a person decides:
|
|
1436
|
+
kind → where → folder → who/what → sync → advanced. */}
|
|
1320
1437
|
{showAddForm && (
|
|
1321
1438
|
<div className="mt-2 rounded-lg bg-white/[0.03] border border-white/[0.08] p-3 space-y-2">
|
|
1322
1439
|
<div className="text-[11px] font-semibold text-white/80">Add Source</div>
|
|
1323
1440
|
{kindsError && (
|
|
1324
1441
|
<div className="text-[9px] text-rose-300/80 bg-rose-500/10 rounded px-2 py-1">
|
|
1325
|
-
Could not load source kinds: {kindsError}. Falling back to
|
|
1442
|
+
Could not load source kinds: {kindsError}. Falling back to the host folder kind.
|
|
1326
1443
|
</div>
|
|
1327
1444
|
)}
|
|
1328
1445
|
{kinds.length > 1 && (
|
|
@@ -1334,7 +1451,8 @@ export function SourcesPanel(props: Props) {
|
|
|
1334
1451
|
const nextKind = e.target.value;
|
|
1335
1452
|
const nextMeta = kindByName.get(nextKind) ?? null;
|
|
1336
1453
|
setAddKind(nextKind);
|
|
1337
|
-
|
|
1454
|
+
setPickerOpen(false);
|
|
1455
|
+
const draft = buildAddDraft(nextMeta, sourceKeyIds, activeScope);
|
|
1338
1456
|
autoKeyRef.current = draft.key;
|
|
1339
1457
|
setAddDraft(draft);
|
|
1340
1458
|
}}
|
|
@@ -1347,10 +1465,64 @@ export function SourcesPanel(props: Props) {
|
|
|
1347
1465
|
))}
|
|
1348
1466
|
</select>
|
|
1349
1467
|
<div className="text-[9px] text-white/30 italic">
|
|
1350
|
-
{selectedKind?.description ?? ''}
|
|
1468
|
+
{kindSummary(selectedKind) ?? selectedKind?.description ?? ''}
|
|
1469
|
+
{selectedKind?.runtimeGated ? ' Serves only while its runtime runs.' : ''}
|
|
1470
|
+
</div>
|
|
1471
|
+
</div>
|
|
1472
|
+
)}
|
|
1473
|
+
{formFields.transport.map(renderConfigField)}
|
|
1474
|
+
<div className="text-[10px] text-white/35 italic">
|
|
1475
|
+
{!selectedKindNeedsPath
|
|
1476
|
+
? 'This connector does not need a local path. Choose the scope; the package owns the runtime binding.'
|
|
1477
|
+
: !rootField.container
|
|
1478
|
+
? 'A folder on your own paired device. Browse opens the typed folder, or starts from the folders that device allows this server; for another one, ask on that device (tray → Allow a folder…, or neuralis device grant).'
|
|
1479
|
+
: isDocker
|
|
1480
|
+
? 'A folder under /mounts — this container’s bind mounts, or a folder inside one. Not there? Add a mount on the host (neuralis mount add) and restart, or attach it from your own device under Where.'
|
|
1481
|
+
: 'On this server: the project’s own folder (an owner may attach a protected zone). Another folder on your computer: choose your device under Where.'}
|
|
1482
|
+
</div>
|
|
1483
|
+
{selectedKindNeedsPath && (
|
|
1484
|
+
<div className="space-y-1">
|
|
1485
|
+
<div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">{rootField.label}</div>
|
|
1486
|
+
<div className="flex gap-1">
|
|
1487
|
+
<input
|
|
1488
|
+
type="text"
|
|
1489
|
+
value={addDraft.path}
|
|
1490
|
+
onChange={(e) => setAddDraft((d) => ({ ...d, path: e.target.value }))}
|
|
1491
|
+
placeholder={isDocker && rootField.container ? '/mounts/<name>' : rootField.placeholder}
|
|
1492
|
+
className={cn(inputCls, 'flex-1')}
|
|
1493
|
+
/>
|
|
1494
|
+
{/* Browse: a device folder through that device's broker (its
|
|
1495
|
+
owner only — the route refuses anyone else), a folder here
|
|
1496
|
+
only for a caller who could attach what they find. */}
|
|
1497
|
+
{rootField.browse && (chosenDevice || canMount) && (
|
|
1498
|
+
<button
|
|
1499
|
+
type="button"
|
|
1500
|
+
onClick={() => setPickerOpen((v) => !v)}
|
|
1501
|
+
className="h-7 px-2 rounded-md bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] text-white/60 hover:text-white/90 transition shrink-0"
|
|
1502
|
+
title="Browse folders"
|
|
1503
|
+
>
|
|
1504
|
+
<FolderOpen className="h-3.5 w-3.5" />
|
|
1505
|
+
</button>
|
|
1506
|
+
)}
|
|
1351
1507
|
</div>
|
|
1352
1508
|
</div>
|
|
1353
1509
|
)}
|
|
1510
|
+
{pickerOpen && selectedKindNeedsPath && (
|
|
1511
|
+
<FolderPicker
|
|
1512
|
+
key={chosenDevice ?? 'server'}
|
|
1513
|
+
agentId={agentId}
|
|
1514
|
+
projectId={projectId}
|
|
1515
|
+
client={client}
|
|
1516
|
+
device={chosenDevice}
|
|
1517
|
+
isDocker={isDocker === true}
|
|
1518
|
+
startPath={addDraft.path}
|
|
1519
|
+
suggestions={chosenDevice ? [] : folderSuggestions}
|
|
1520
|
+
wantWrite={addDraft.write}
|
|
1521
|
+
onSelect={selectPickedFolder}
|
|
1522
|
+
onClose={() => setPickerOpen(false)}
|
|
1523
|
+
/>
|
|
1524
|
+
)}
|
|
1525
|
+
{formFields.fields.map(renderConfigField)}
|
|
1354
1526
|
{selectedKind?.scopeSelectable && (
|
|
1355
1527
|
<div className="space-y-1">
|
|
1356
1528
|
<div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">Scope</div>
|
|
@@ -1369,215 +1541,27 @@ export function SourcesPanel(props: Props) {
|
|
|
1369
1541
|
}}
|
|
1370
1542
|
className={selectCls}
|
|
1371
1543
|
>
|
|
1372
|
-
{scopeOptions(selectedKind).map((scope) => (
|
|
1544
|
+
{scopeOptions(selectedKind, addDraft.config).map((scope) => (
|
|
1373
1545
|
<option key={scope} value={scope}>{scope}</option>
|
|
1374
1546
|
))}
|
|
1375
1547
|
</select>
|
|
1376
1548
|
</div>
|
|
1377
1549
|
)}
|
|
1378
|
-
{showContainerPathChrome && isDocker ? (
|
|
1379
|
-
<>
|
|
1380
|
-
<div className="text-[10px] text-amber-300/70 bg-amber-500/8 rounded px-2 py-1.5">
|
|
1381
|
-
Docker mode: host folders still need the CLI mount script:{' '}
|
|
1382
|
-
<code className="text-amber-200/90 font-mono">neuralis mount add</code>{' '}
|
|
1383
|
-
on the host, then restart the container. Package sources like Machine do not require a host path here.
|
|
1384
|
-
</div>
|
|
1385
|
-
{/* Unmounted volume mounts */}
|
|
1386
|
-
{dockerMountsLoading ? (
|
|
1387
|
-
<div className="flex items-center gap-1.5 py-1">
|
|
1388
|
-
<Loader2 className="h-3 w-3 animate-spin text-white/30" />
|
|
1389
|
-
<span className="text-[9px] text-white/35">Scanning volume mounts...</span>
|
|
1390
|
-
</div>
|
|
1391
|
-
) : dockerMounts.length > 0 ? (
|
|
1392
|
-
<div className="space-y-1">
|
|
1393
|
-
<div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">
|
|
1394
|
-
Available volume mounts (not yet configured)
|
|
1395
|
-
</div>
|
|
1396
|
-
{dockerMounts.map((mount) => (
|
|
1397
|
-
<div key={mount.path} className="flex items-center gap-2 rounded px-2 py-1.5 bg-white/[0.02] hover:bg-white/[0.04] transition">
|
|
1398
|
-
<Folder className="h-3 w-3 text-cyan-400/70 shrink-0" />
|
|
1399
|
-
<span className="text-[10px] text-white/75 font-medium flex-1 truncate">{mount.name}</span>
|
|
1400
|
-
<span className="text-[9px] text-white/30 font-mono truncate max-w-[120px]">{mount.path}</span>
|
|
1401
|
-
<button
|
|
1402
|
-
type="button"
|
|
1403
|
-
onClick={() => {
|
|
1404
|
-
setAddDraft((d) => ({ ...d, key: mount.name, path: mount.path }));
|
|
1405
|
-
}}
|
|
1406
|
-
className="px-2 py-0.5 rounded text-[8px] font-semibold text-cyan-300/80 bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-400/20 transition shrink-0"
|
|
1407
|
-
>
|
|
1408
|
-
+ Add
|
|
1409
|
-
</button>
|
|
1410
|
-
</div>
|
|
1411
|
-
))}
|
|
1412
|
-
</div>
|
|
1413
|
-
) : (
|
|
1414
|
-
<div className="text-[9px] text-white/30 italic">
|
|
1415
|
-
All volume mounts are configured as sources.
|
|
1416
|
-
</div>
|
|
1417
|
-
)}
|
|
1418
|
-
</>
|
|
1419
|
-
) : (
|
|
1420
|
-
<div className="text-[10px] text-white/35 italic">
|
|
1421
|
-
{!selectedKindNeedsPath
|
|
1422
|
-
? 'This connector does not need a local path. Choose the scope and URI scheme; the package owns the runtime binding.'
|
|
1423
|
-
: rootField.container
|
|
1424
|
-
? 'Mount any folder from your system. The path must be accessible to the server process.'
|
|
1425
|
-
: 'A folder on the device you choose below \u2014 your own paired machine. It needs no volume mount here; that device\u2019s own allowlist (neuralis device grant) still limits what is reachable.'}
|
|
1426
|
-
</div>
|
|
1427
|
-
)}
|
|
1428
|
-
<div className="space-y-2">
|
|
1429
|
-
<div className={cn('grid gap-2', selectedKindNeedsPath ? 'grid-cols-2' : 'grid-cols-1')}>
|
|
1430
|
-
<div className="space-y-1">
|
|
1431
|
-
<div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">URI scheme</div>
|
|
1432
|
-
<input
|
|
1433
|
-
type="text"
|
|
1434
|
-
value={addDraft.key}
|
|
1435
|
-
onChange={(e) => setAddDraft((d) => ({ ...d, key: e.target.value }))}
|
|
1436
|
-
placeholder={computeSuggestedKey(addDraft.scope, addDraft.path)}
|
|
1437
|
-
className={inputCls}
|
|
1438
|
-
/>
|
|
1439
|
-
<div className="text-[8px] text-white/25 font-mono">{addDraft.key || computeSuggestedKey(addDraft.scope, addDraft.path)}://</div>
|
|
1440
|
-
</div>
|
|
1441
|
-
{selectedKindNeedsPath && (
|
|
1442
|
-
<div className="space-y-1">
|
|
1443
|
-
<div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">{rootField.label}</div>
|
|
1444
|
-
<div className="flex gap-1">
|
|
1445
|
-
<input
|
|
1446
|
-
type="text"
|
|
1447
|
-
value={addDraft.path}
|
|
1448
|
-
onChange={(e) => setAddDraft((d) => ({ ...d, path: e.target.value }))}
|
|
1449
|
-
placeholder={rootField.placeholder}
|
|
1450
|
-
className={cn(inputCls, 'flex-1')}
|
|
1451
|
-
/>
|
|
1452
|
-
{/* Browse: container-plane roots only (the route readdirs in
|
|
1453
|
-
the SERVER process), outside Docker, and only for a caller
|
|
1454
|
-
who could actually attach what they find. */}
|
|
1455
|
-
{rootField.container && !isDocker && canMount && (
|
|
1456
|
-
<button
|
|
1457
|
-
type="button"
|
|
1458
|
-
onClick={() => void openBrowser(addDraft.path || '/')}
|
|
1459
|
-
className="h-7 px-2 rounded-md bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] text-white/60 hover:text-white/90 transition shrink-0"
|
|
1460
|
-
title="Browse directories"
|
|
1461
|
-
>
|
|
1462
|
-
<FolderOpen className="h-3.5 w-3.5" />
|
|
1463
|
-
</button>
|
|
1464
|
-
)}
|
|
1465
|
-
</div>
|
|
1466
|
-
</div>
|
|
1467
|
-
)}
|
|
1468
|
-
</div>
|
|
1469
|
-
{Object.entries(schemaObject(selectedKind).properties)
|
|
1470
|
-
.filter(([key]) => key !== 'root')
|
|
1471
|
-
.map(([key, prop]) => {
|
|
1472
|
-
const field = fieldControl(prop, selectedKind?.packageId ?? '');
|
|
1473
|
-
const setValue = (value: string) => setAddDraft((d) => ({
|
|
1474
|
-
...d,
|
|
1475
|
-
config: { ...d.config, [key]: value },
|
|
1476
|
-
}));
|
|
1477
|
-
return (
|
|
1478
|
-
<div key={key} className="space-y-1">
|
|
1479
|
-
<div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">
|
|
1480
|
-
{(prop as any)?.['x-neuralis']?.ui?.label ?? key}
|
|
1481
|
-
</div>
|
|
1482
|
-
{field.control === 'select' ? (
|
|
1483
|
-
<OptionsRouteSelect
|
|
1484
|
-
route={field.options}
|
|
1485
|
-
revision={deviceRevision}
|
|
1486
|
-
projectId={projectId}
|
|
1487
|
-
agentId={agentId}
|
|
1488
|
-
value={String(addDraft.config[key] ?? '')}
|
|
1489
|
-
onChange={setValue}
|
|
1490
|
-
/>
|
|
1491
|
-
) : (
|
|
1492
|
-
<input
|
|
1493
|
-
type={field.numeric ? 'number' : 'text'}
|
|
1494
|
-
value={String(addDraft.config[key] ?? '')}
|
|
1495
|
-
onChange={(e) => setValue(e.target.value)}
|
|
1496
|
-
placeholder={(prop as any)?.['x-neuralis']?.ui?.placeholder ?? (prop as any)?.description ?? key}
|
|
1497
|
-
className={inputCls}
|
|
1498
|
-
/>
|
|
1499
|
-
)}
|
|
1500
|
-
{field.control === 'text' && field.refused ? (
|
|
1501
|
-
<div className="text-[9px] text-amber-300/80">{field.refused}</div>
|
|
1502
|
-
) : null}
|
|
1503
|
-
</div>
|
|
1504
|
-
);
|
|
1505
|
-
})}
|
|
1506
|
-
{/* Directory browser (non-Docker only) */}
|
|
1507
|
-
{showContainerPathChrome && !isDocker && browseOpen && (
|
|
1508
|
-
<div className="rounded-md bg-white/[0.03] border border-white/[0.1] overflow-hidden">
|
|
1509
|
-
<div className="flex items-center gap-1.5 px-2 py-1.5 bg-white/[0.02] border-b border-white/[0.06]">
|
|
1510
|
-
<FolderOpen className="h-3 w-3 text-cyan-400 shrink-0" />
|
|
1511
|
-
<span className="text-[9px] font-mono text-white/60 truncate flex-1">{browsePath}</span>
|
|
1512
|
-
<button type="button" onClick={() => setBrowseOpen(false)}
|
|
1513
|
-
className="text-[9px] text-white/40 hover:text-white/70 px-1">Close</button>
|
|
1514
|
-
</div>
|
|
1515
|
-
{browseError && (
|
|
1516
|
-
<div className="px-2 py-1 text-[9px] text-red-300/80">{browseError}</div>
|
|
1517
|
-
)}
|
|
1518
|
-
{browseLoading ? (
|
|
1519
|
-
<div className="flex items-center justify-center py-3">
|
|
1520
|
-
<Loader2 className="h-3 w-3 animate-spin text-white/30" />
|
|
1521
|
-
</div>
|
|
1522
|
-
) : (
|
|
1523
|
-
<div className="max-h-[180px] overflow-y-auto">
|
|
1524
|
-
{browsePath !== '/' && (
|
|
1525
|
-
<button
|
|
1526
|
-
type="button"
|
|
1527
|
-
onClick={() => {
|
|
1528
|
-
const parent = browsePath.replace(/[/\\][^/\\]+$/, '') || '/';
|
|
1529
|
-
void openBrowser(parent);
|
|
1530
|
-
}}
|
|
1531
|
-
className="w-full flex items-center gap-1.5 px-2 py-1 text-left hover:bg-white/[0.04] transition"
|
|
1532
|
-
>
|
|
1533
|
-
<ChevronUp className="h-3 w-3 text-white/30" />
|
|
1534
|
-
<span className="text-[10px] text-white/50">..</span>
|
|
1535
|
-
</button>
|
|
1536
|
-
)}
|
|
1537
|
-
{browseEntries.length === 0 && !browseLoading && (
|
|
1538
|
-
<div className="px-2 py-2 text-[9px] text-white/30 italic">No subdirectories</div>
|
|
1539
|
-
)}
|
|
1540
|
-
{browseEntries.map((entry) => (
|
|
1541
|
-
<div key={entry.path} className="flex items-center gap-0.5 hover:bg-white/[0.04] transition">
|
|
1542
|
-
<button
|
|
1543
|
-
type="button"
|
|
1544
|
-
onClick={() => void openBrowser(entry.path)}
|
|
1545
|
-
className="flex items-center gap-1.5 px-2 py-1 flex-1 text-left min-w-0"
|
|
1546
|
-
>
|
|
1547
|
-
<Folder className="h-3 w-3 text-cyan-400/70 shrink-0" />
|
|
1548
|
-
<span className="text-[10px] text-white/75 truncate">{entry.name}</span>
|
|
1549
|
-
</button>
|
|
1550
|
-
<button
|
|
1551
|
-
type="button"
|
|
1552
|
-
onClick={() => selectBrowsePath(entry.path)}
|
|
1553
|
-
className="px-2 py-1 text-[8px] font-medium text-cyan-300/70 hover:text-cyan-200 shrink-0"
|
|
1554
|
-
>
|
|
1555
|
-
Select
|
|
1556
|
-
</button>
|
|
1557
|
-
</div>
|
|
1558
|
-
))}
|
|
1559
|
-
<button
|
|
1560
|
-
type="button"
|
|
1561
|
-
onClick={() => selectBrowsePath(browsePath)}
|
|
1562
|
-
className="w-full flex items-center gap-1.5 px-2 py-1.5 text-left bg-cyan-500/5 hover:bg-cyan-500/10 border-t border-white/[0.06] transition"
|
|
1563
|
-
>
|
|
1564
|
-
<CheckCircle2 className="h-3 w-3 text-cyan-400 shrink-0" />
|
|
1565
|
-
<span className="text-[10px] font-medium text-cyan-300/80">Select this folder</span>
|
|
1566
|
-
<span className="text-[9px] text-white/30 font-mono truncate ml-auto">{browsePath}</span>
|
|
1567
|
-
</button>
|
|
1568
|
-
</div>
|
|
1569
|
-
)}
|
|
1570
|
-
</div>
|
|
1571
|
-
)}
|
|
1572
|
-
</div>
|
|
1573
1550
|
<div className="flex items-center gap-2">
|
|
1574
1551
|
<span className="text-[9px] font-semibold uppercase tracking-wider text-white/35 w-16 shrink-0">Access</span>
|
|
1575
1552
|
{([
|
|
1576
1553
|
{ key: 'read' as const, label: 'Read', icon: Eye },
|
|
1577
1554
|
{ key: 'write' as const, label: 'Write', icon: Pencil },
|
|
1578
1555
|
{ key: 'exec' as const, label: 'Exec', icon: Terminal },
|
|
1579
|
-
])
|
|
1580
|
-
|
|
1556
|
+
])
|
|
1557
|
+
// Exec is a SOURCE policy bit: offered wherever a shell can run in
|
|
1558
|
+
// the kind's sources at all (`shell`, the server's answer). Who may
|
|
1559
|
+
// then run one is the caller's own feature (`execRequires`, the
|
|
1560
|
+
// tooltip) — never a reason to hide the bit from the person setting
|
|
1561
|
+
// it for others.
|
|
1562
|
+
.filter(({ key }) => key !== 'exec' || selectedKind?.shell !== false)
|
|
1563
|
+
.map(({ key, label, icon: PIcon }) => (
|
|
1564
|
+
<label key={key} title={key === 'exec' && selectedKind?.execRequires?.length ? `Running programs here needs ${selectedKind.execRequires.join(' or ')}` : undefined} className={cn(
|
|
1581
1565
|
'inline-flex items-center gap-1 rounded px-2 py-1 text-[9px] cursor-pointer transition',
|
|
1582
1566
|
addDraft[key] ? 'bg-white/[0.06] text-white/85' : 'bg-white/[0.02] text-white/40',
|
|
1583
1567
|
)}>
|
|
@@ -1588,20 +1572,25 @@ export function SourcesPanel(props: Props) {
|
|
|
1588
1572
|
</label>
|
|
1589
1573
|
))}
|
|
1590
1574
|
</div>
|
|
1591
|
-
<div className="space-y-1">
|
|
1592
|
-
<div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">Description <span className="text-white/20 normal-case font-normal">(optional — shown to the agent)</span></div>
|
|
1593
|
-
{/* The connector's own text as the PLACEHOLDER, not as a value:
|
|
1594
|
-
leaving it blank resolves through the same chain at read time,
|
|
1595
|
-
so the agent still gets the package's wording — and it keeps
|
|
1596
|
-
tracking the package instead of freezing a copy here. */}
|
|
1597
|
-
<input value={addDraft.description} onChange={(e) => setAddDraft((d) => ({ ...d, description: e.target.value }))} className={inputCls} placeholder={selectedKind?.description ?? 'What this source is for…'} maxLength={200} />
|
|
1598
|
-
</div>
|
|
1599
1575
|
{selectedKindSupportsSync && (
|
|
1600
1576
|
<div className="flex items-center gap-2">
|
|
1601
1577
|
<label className="inline-flex items-center gap-1.5 text-[10px] text-white/80 cursor-pointer">
|
|
1602
1578
|
<input type="checkbox" checked={addDraft.syncEnabled} onChange={(e) => setAddDraft((d) => ({ ...d, syncEnabled: e.target.checked }))} className="accent-emerald-400 h-3 w-3" />
|
|
1603
1579
|
Enable sync
|
|
1604
1580
|
</label>
|
|
1581
|
+
{addDraft.syncEnabled && (
|
|
1582
|
+
<select
|
|
1583
|
+
value={addDraft.trigger.startsWith('interval:') ? 'manual' : addDraft.trigger}
|
|
1584
|
+
onChange={(e) => setAddDraft((d) => ({ ...d, trigger: e.target.value as SyncTriggerValue }))}
|
|
1585
|
+
className={cn(selectCls, 'w-auto')}
|
|
1586
|
+
title="When the folder syncs (a schedule is set on the source after it is added)"
|
|
1587
|
+
>
|
|
1588
|
+
{addDraft.trigger.startsWith('interval:') && <option value={addDraft.trigger}>On schedule ({addDraft.trigger.slice('interval:'.length)})</option>}
|
|
1589
|
+
{TRIGGER_OPTIONS.filter((o) => o.value !== 'interval').map((o) => (
|
|
1590
|
+
<option key={o.value} value={o.value}>{o.label}</option>
|
|
1591
|
+
))}
|
|
1592
|
+
</select>
|
|
1593
|
+
)}
|
|
1605
1594
|
</div>
|
|
1606
1595
|
)}
|
|
1607
1596
|
{selectedKindSupportsSync && addDraft.syncEnabled && (
|
|
@@ -1617,22 +1606,48 @@ export function SourcesPanel(props: Props) {
|
|
|
1617
1606
|
<div className="text-[8px] text-white/25 italic">Non-empty: only matching files sync. Excludes always win — an excluded path can never be re-included. This narrows the INDEX, not what the agent can read: the source root does that.</div>
|
|
1618
1607
|
</div>
|
|
1619
1608
|
)}
|
|
1620
|
-
|
|
1621
|
-
<
|
|
1622
|
-
<
|
|
1609
|
+
<details className="group rounded-md bg-white/[0.02] px-2 py-1">
|
|
1610
|
+
<summary className="cursor-pointer select-none text-[9px] font-semibold uppercase tracking-wider text-white/35 hover:text-white/60">
|
|
1611
|
+
Advanced <span className="normal-case font-normal text-white/25">· {addDraft.key || computeSuggestedKey(addDraft.scope, addDraft.path)}://</span>
|
|
1612
|
+
</summary>
|
|
1613
|
+
<div className="mt-2 space-y-2 pb-1">
|
|
1614
|
+
<div className="space-y-1">
|
|
1615
|
+
<div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">URI scheme</div>
|
|
1623
1616
|
<input
|
|
1624
|
-
type="
|
|
1625
|
-
|
|
1626
|
-
onChange={(e) => setAddDraft((d) => ({ ...d,
|
|
1627
|
-
|
|
1617
|
+
type="text"
|
|
1618
|
+
value={addDraft.key}
|
|
1619
|
+
onChange={(e) => setAddDraft((d) => ({ ...d, key: e.target.value }))}
|
|
1620
|
+
placeholder={computeSuggestedKey(addDraft.scope, addDraft.path)}
|
|
1621
|
+
className={inputCls}
|
|
1628
1622
|
/>
|
|
1629
|
-
<
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1623
|
+
<div className="text-[8px] text-white/25 font-mono">{addDraft.key || computeSuggestedKey(addDraft.scope, addDraft.path)}://</div>
|
|
1624
|
+
</div>
|
|
1625
|
+
{formFields.advanced.map(renderConfigField)}
|
|
1626
|
+
<div className="space-y-1">
|
|
1627
|
+
<div className="text-[9px] font-semibold uppercase tracking-wider text-white/35">Description <span className="text-white/20 normal-case font-normal">(optional — shown to the agent)</span></div>
|
|
1628
|
+
{/* The connector's own text as the PLACEHOLDER, not as a value:
|
|
1629
|
+
leaving it blank resolves through the same chain at read time,
|
|
1630
|
+
so the agent still gets the package's wording — and it keeps
|
|
1631
|
+
tracking the package instead of freezing a copy here. */}
|
|
1632
|
+
<input value={addDraft.description} onChange={(e) => setAddDraft((d) => ({ ...d, description: e.target.value }))} className={inputCls} placeholder={selectedKind?.description ?? 'What this source is for…'} maxLength={200} />
|
|
1633
|
+
</div>
|
|
1634
|
+
{addKind === HOST_KIND && isOwnerEffective && (
|
|
1635
|
+
<PackageFoldersField
|
|
1636
|
+
value={addDraft.packageFolders}
|
|
1637
|
+
onChange={(packageFolders) => setAddDraft((d) => ({ ...d, packageFolders }))}
|
|
1638
|
+
note="as soon as the source is added"
|
|
1639
|
+
/>
|
|
1640
|
+
)}
|
|
1641
|
+
</div>
|
|
1642
|
+
</details>
|
|
1643
|
+
{chosenDevice && deviceGuard !== null && deviceGuard === addDraft.path && (
|
|
1644
|
+
<div className="rounded-md border border-amber-400/20 bg-amber-500/[0.04] px-2 py-1.5 space-y-1">
|
|
1645
|
+
<div className="text-[9px] text-amber-300/80">{DEVICE_FOLDER_NOT_ALLOWED} Ask there, or attach it now — it serves once the device allows it.</div>
|
|
1646
|
+
<DeviceFolderAsk key={deviceGuard} device={chosenDevice} projectId={projectId} agentId={agentId} path={deviceGuard} wantWrite={addDraft.write} />
|
|
1647
|
+
<button type="button" onClick={() => void handleAddSource(true)} disabled={addCreating}
|
|
1648
|
+
className="h-6 px-2.5 rounded-md bg-white/[0.04] border border-white/[0.1] text-[9px] font-semibold text-white/70 hover:bg-white/[0.08] transition disabled:opacity-35">
|
|
1649
|
+
Attach anyway — it serves once allowed
|
|
1650
|
+
</button>
|
|
1636
1651
|
</div>
|
|
1637
1652
|
)}
|
|
1638
1653
|
<div className="flex items-center gap-1.5 pt-1">
|
|
@@ -1657,7 +1672,7 @@ export function SourcesPanel(props: Props) {
|
|
|
1657
1672
|
{grouped.map(([origin, rows]) => (
|
|
1658
1673
|
<div key={origin}>
|
|
1659
1674
|
{/* Origin group header — small, indented a touch LEFT of its rows,
|
|
1660
|
-
replaces the per-row "
|
|
1675
|
+
replaces the per-row "native" badge. */}
|
|
1661
1676
|
<div className="-ml-0.5 mb-0.5 flex items-center gap-1.5 px-1">
|
|
1662
1677
|
<Package className="h-3 w-3 shrink-0 text-white/30" />
|
|
1663
1678
|
<span className="truncate font-mono text-[10px] font-medium text-white/40">{origin}</span>
|
|
@@ -1689,13 +1704,14 @@ export function SourcesPanel(props: Props) {
|
|
|
1689
1704
|
const dirty = editable && isDirty(config, draft);
|
|
1690
1705
|
const tp = parseTrigger(syncDraft.trigger);
|
|
1691
1706
|
const sections = subSections[config.source] ?? {};
|
|
1707
|
+
const watchBadge = sourceWatchBadge(config.watch, config.live_reason);
|
|
1692
1708
|
|
|
1693
1709
|
return (
|
|
1694
1710
|
<div key={fileKey(sourceRootUri(config.source))} className="rounded-sm">
|
|
1695
1711
|
{/* Accordion header: selection and enabled actions are siblings. */}
|
|
1696
1712
|
<div className="w-full flex items-center gap-2 py-2 text-left group hover:bg-white/[0.02] rounded-md px-1 transition">
|
|
1697
1713
|
<button type="button" onClick={() => toggleSource(config.source)} aria-expanded={isOpen}
|
|
1698
|
-
title={disconnected ? sourceLiveReasonLabel(config.live_reason) : undefined}
|
|
1714
|
+
title={disconnected ? sourceLiveReasonLabel(config.live_reason, { kind: config.connection.kind, notReady: config.live_not_ready }) : undefined}
|
|
1699
1715
|
className="min-w-0 flex-1 flex items-center gap-2 text-left">
|
|
1700
1716
|
{/* Git indicator (G2.1) — a GitBranch overlay on a git-repo
|
|
1701
1717
|
source's icon, ORTHOGONAL to the sync tone (visualTone still
|
|
@@ -1709,6 +1725,17 @@ export function SourcesPanel(props: Props) {
|
|
|
1709
1725
|
<SourceIcon className={cn('h-4 w-4 shrink-0', visualTone.iconClassName)} />
|
|
1710
1726
|
)}
|
|
1711
1727
|
<span className={cn('min-w-0 flex-1 truncate text-[12.5px] font-semibold', dimmed ? 'text-white/45' : 'text-white/90')}>{config.source}</span>
|
|
1728
|
+
{watchBadge ? (
|
|
1729
|
+
<span
|
|
1730
|
+
className={cn(
|
|
1731
|
+
'shrink-0 rounded px-1 text-[9px] uppercase tracking-wider',
|
|
1732
|
+
watchBadge.tone === 'pending' ? 'text-white/55 bg-white/[0.06]' : 'text-amber-200/80 bg-amber-500/10',
|
|
1733
|
+
)}
|
|
1734
|
+
title={watchBadge.title}
|
|
1735
|
+
>
|
|
1736
|
+
{watchBadge.label}
|
|
1737
|
+
</span>
|
|
1738
|
+
) : null}
|
|
1712
1739
|
{config.git?.isRepo && config.git.branch ? (
|
|
1713
1740
|
<span
|
|
1714
1741
|
className="inline-flex max-w-[96px] shrink-0 items-center gap-0.5 text-[10px] text-violet-300/70"
|
|
@@ -1812,10 +1839,16 @@ export function SourcesPanel(props: Props) {
|
|
|
1812
1839
|
</div>
|
|
1813
1840
|
|
|
1814
1841
|
{configSupportsSync && !config.sync?.lastSync ? (
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1842
|
+
config.sync?.lastSyncResult?.error ? (
|
|
1843
|
+
<div className="rounded-md border border-rose-300/15 bg-rose-500/5 px-2 py-1.5 text-[10px] text-rose-100/70">
|
|
1844
|
+
{syncErrorLabel(config.sync.lastSyncResult.error)}
|
|
1845
|
+
</div>
|
|
1846
|
+
) : (
|
|
1847
|
+
<div className="rounded-md border border-sky-300/15 bg-sky-500/5 px-2 py-1.5 text-[10px] text-sky-100/70">
|
|
1848
|
+
Never synced — run a sync to index this source and load its packages
|
|
1849
|
+
(skills, rules, instructions) for agents.
|
|
1850
|
+
</div>
|
|
1851
|
+
)
|
|
1819
1852
|
) : null}
|
|
1820
1853
|
|
|
1821
1854
|
{config.observed ? (
|
|
@@ -1878,27 +1911,15 @@ export function SourcesPanel(props: Props) {
|
|
|
1878
1911
|
)}
|
|
1879
1912
|
</div>
|
|
1880
1913
|
|
|
1881
|
-
{/* R2b —
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
{editable && isOwnerEffective && (meta?.kind ?? config.connection.kind) ===
|
|
1885
|
-
<
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
checked={draft.recognizesPackages ?? false}
|
|
1891
|
-
onChange={(e) => updateDraft(config.source, (d) => ({ ...d, recognizesPackages: e.target.checked }))}
|
|
1892
|
-
className="accent-cyan-400 h-3 w-3 mt-0.5"
|
|
1893
|
-
/>
|
|
1894
|
-
<span className="text-[10px] text-white/70 leading-tight">
|
|
1895
|
-
Recognize packages
|
|
1896
|
-
<span className="block text-[9px] text-white/35 normal-case font-normal">
|
|
1897
|
-
Load packages found in this folder (in addition to <code>_packages/</code>) — applied on save; advertised only within this source's scope.
|
|
1898
|
-
</span>
|
|
1899
|
-
</span>
|
|
1900
|
-
</label>
|
|
1901
|
-
</div>
|
|
1914
|
+
{/* R2b — package folders. Owner/admin-effective + folder kind
|
|
1915
|
+
only (server re-gates). Makes those folders package sources
|
|
1916
|
+
too; advertised only within the source's own scope. */}
|
|
1917
|
+
{editable && isOwnerEffective && (meta?.kind ?? config.connection.kind) === HOST_KIND && (
|
|
1918
|
+
<PackageFoldersField
|
|
1919
|
+
value={formatPackageFolders(draft.packageFolders)}
|
|
1920
|
+
onChange={(text) => updateDraft(config.source, (d) => ({ ...d, packageFolders: parsePackageFolders(text) }))}
|
|
1921
|
+
note="applied on save"
|
|
1922
|
+
/>
|
|
1902
1923
|
)}
|
|
1903
1924
|
|
|
1904
1925
|
{/* Permissions section — admin-style compact editor.
|
|
@@ -2069,7 +2090,10 @@ export function SourcesPanel(props: Props) {
|
|
|
2069
2090
|
<span>Last sync: +{config.sync.lastSyncResult.filesAdded} -{config.sync.lastSyncResult.filesRemoved} ~{config.sync.lastSyncResult.filesUpdated}</span>
|
|
2070
2091
|
<span>({(config.sync.lastSyncResult.durationMs / 1000).toFixed(1)}s)</span>
|
|
2071
2092
|
{config.sync.lastSyncResult.error && (
|
|
2072
|
-
<span className="text-rose-300/80 truncate max-w-[200px]" title={config.sync.lastSyncResult.error}>err</span>
|
|
2093
|
+
<span className="text-rose-300/80 truncate max-w-[200px]" title={syncErrorLabel(config.sync.lastSyncResult.error)}>err</span>
|
|
2094
|
+
)}
|
|
2095
|
+
{config.sync.lastSyncResult.error === 'root_empty' && (
|
|
2096
|
+
<button type="button" className="text-rose-200/80 underline" onClick={() => void handleSync(config.source, true)}>Sync anyway</button>
|
|
2073
2097
|
)}
|
|
2074
2098
|
</div>
|
|
2075
2099
|
)}
|
|
@@ -2213,9 +2237,7 @@ export function SourcesPanel(props: Props) {
|
|
|
2213
2237
|
<SourceDeleteConfirm
|
|
2214
2238
|
source={config.source}
|
|
2215
2239
|
vectorCount={typeof config.indexed_count === 'number' ? config.indexed_count : null}
|
|
2216
|
-
isDockerMount={
|
|
2217
|
-
(config.connection?.config as { hostRoot?: unknown } | undefined)?.hostRoot,
|
|
2218
|
-
)}
|
|
2240
|
+
isDockerMount={isDockerBind(config)}
|
|
2219
2241
|
busy={deleting}
|
|
2220
2242
|
onCancel={() => setDeleteConfirmFor(null)}
|
|
2221
2243
|
onConfirm={(opts) => void handleDelete(config.source, opts)}
|
|
@@ -2401,6 +2423,7 @@ const DIMMED_OPTION_STYLE = {
|
|
|
2401
2423
|
*/
|
|
2402
2424
|
function OptionsRouteSelect({
|
|
2403
2425
|
route,
|
|
2426
|
+
emptyLabel,
|
|
2404
2427
|
revision,
|
|
2405
2428
|
projectId,
|
|
2406
2429
|
agentId,
|
|
@@ -2408,11 +2431,14 @@ function OptionsRouteSelect({
|
|
|
2408
2431
|
onChange,
|
|
2409
2432
|
}: {
|
|
2410
2433
|
route: OptionsRoute;
|
|
2434
|
+
/** An optional field's empty choice (`fieldControl`), selectable — absent ⇒ a disabled "Choose…". */
|
|
2435
|
+
emptyLabel?: string;
|
|
2411
2436
|
revision: number;
|
|
2412
2437
|
projectId?: string | null;
|
|
2413
2438
|
agentId: string;
|
|
2414
2439
|
value: string;
|
|
2415
|
-
|
|
2440
|
+
/** The picked value and its row (`undefined` for the empty choice). */
|
|
2441
|
+
onChange: (value: string, option?: SelectOption) => void;
|
|
2416
2442
|
}) {
|
|
2417
2443
|
const [options, setOptions] = useState<SelectOption[] | null>(null);
|
|
2418
2444
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
@@ -2443,10 +2469,10 @@ function OptionsRouteSelect({
|
|
|
2443
2469
|
|
|
2444
2470
|
if (loadError) return <div className="text-[9px] text-red-300/80">{loadError}</div>;
|
|
2445
2471
|
if (options === null) return <div className="text-[9px] text-white/30 animate-pulse">Loading…</div>;
|
|
2446
|
-
if (options.length === 0) return <div className="text-[9px] text-white/40 italic">Nothing to choose from.</div>;
|
|
2472
|
+
if (options.length === 0 && !emptyLabel) return <div className="text-[9px] text-white/40 italic">Nothing to choose from.</div>;
|
|
2447
2473
|
return (
|
|
2448
|
-
<select value={value} onChange={(e) => onChange(e.target.value)} className={selectCls}>
|
|
2449
|
-
<option value="" disabled>Choose…</option>
|
|
2474
|
+
<select value={value} onChange={(e) => onChange(e.target.value, options.find((o) => o.value === e.target.value))} className={selectCls}>
|
|
2475
|
+
{emptyLabel ? <option value="">{emptyLabel}</option> : <option value="" disabled>Choose…</option>}
|
|
2450
2476
|
{options.map((o) => (
|
|
2451
2477
|
<option key={o.value} value={o.value} style={o.dimmed ? DIMMED_OPTION_STYLE : undefined}>
|
|
2452
2478
|
{o.label}
|
|
@@ -2455,3 +2481,37 @@ function OptionsRouteSelect({
|
|
|
2455
2481
|
</select>
|
|
2456
2482
|
);
|
|
2457
2483
|
}
|
|
2484
|
+
|
|
2485
|
+
/**
|
|
2486
|
+
* The package-folders field both forms show — one folder per line, relative to
|
|
2487
|
+
* the source root, `.` for the whole folder. It keeps its OWN text (a list
|
|
2488
|
+
* round-trip would swallow the newline being typed) and reports every edit;
|
|
2489
|
+
* the caller parses (`parsePackageFolders`). The list is UX: the server
|
|
2490
|
+
* re-validates the folders and re-gates the owner/admin write.
|
|
2491
|
+
*/
|
|
2492
|
+
function PackageFoldersField({ value, onChange, note }: { value: string; onChange: (text: string) => void; note: string }) {
|
|
2493
|
+
const [text, setText] = useState(value);
|
|
2494
|
+
// An external change (a reset, a saved answer) replaces the text; an echo of
|
|
2495
|
+
// this field's own edit does not.
|
|
2496
|
+
useEffect(() => {
|
|
2497
|
+
setText((current) => (parsePackageFolders(current).join('\n') === parsePackageFolders(value).join('\n') ? current : value));
|
|
2498
|
+
}, [value]);
|
|
2499
|
+
return (
|
|
2500
|
+
<div className="space-y-1">
|
|
2501
|
+
<div className="flex items-center gap-1.5 text-[9px] font-semibold uppercase tracking-wider text-white/35">
|
|
2502
|
+
<Package className="h-3 w-3 text-cyan-400 shrink-0" />
|
|
2503
|
+
Package folders
|
|
2504
|
+
</div>
|
|
2505
|
+
<textarea
|
|
2506
|
+
value={text}
|
|
2507
|
+
onChange={(e) => { setText(e.target.value); onChange(e.target.value); }}
|
|
2508
|
+
rows={2}
|
|
2509
|
+
className={cn(textareaCls, 'font-mono')}
|
|
2510
|
+
placeholder={'# empty = none\n.\ntools/pkgs'}
|
|
2511
|
+
/>
|
|
2512
|
+
<div className="text-[8px] text-white/25 italic">
|
|
2513
|
+
Load the packages found in these folders (in addition to <code>_packages/</code>), {note}; <code>.</code> is the whole folder. Advertised only within this source's scope.
|
|
2514
|
+
</div>
|
|
2515
|
+
</div>
|
|
2516
|
+
);
|
|
2517
|
+
}
|