@openforge-app/plugin-sdk 0.2.8 → 0.2.10
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/dist/backend.d.ts +2 -2
- package/dist/collapsibleSectionState.js +30 -12
- package/dist/domain.d.ts +1 -0
- package/dist/domain.js +2 -1
- package/dist/frontend.d.ts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/manifest.d.ts +1 -0
- package/dist/manifest.js +10 -4
- package/dist/markdown.d.ts +9 -0
- package/dist/markdown.js +57 -1
- package/dist/openforgePackageMetadataSchema.json +4 -12
- package/dist/publicEntrypoints.d.mts +26 -0
- package/dist/publicEntrypoints.mjs +103 -0
- package/dist/publicUiExports.mjs +2 -0
- package/dist/taskBrowserDevToolsShortcuts.d.ts +12 -0
- package/dist/taskBrowserDevToolsShortcuts.js +20 -0
- package/dist/testing/commonApiFake.js +206 -6
- package/dist/testing/contracts.d.ts +18 -4
- package/dist/testing/frontendContributionFake.d.ts +6 -4
- package/dist/testing/frontendContributionFake.js +22 -32
- package/dist/testing/support.d.ts +5 -2
- package/dist/testing/support.js +12 -1
- package/dist/types.d.ts +106 -19
- package/dist/types.js +2 -1
- package/dist/ui/Modal.svelte +33 -3
- package/dist/ui/PluginPageHeader.svelte +11 -4
- package/dist/ui/PluginPageShell.svelte +20 -0
- package/dist/ui/ProjectFileTree.svelte +192 -0
- package/dist/vite.js +7 -17
- package/package.json +24 -18
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Component } from 'svelte';
|
|
2
2
|
import type { BrowserSurfacesAPI } from './browserSurfaces';
|
|
3
|
-
import type { AgentSession, CommandInfo, FileContent, FileEntry, Project, ProjectAttention, Task, TaskWorkspaceInfo, WritableBoardStatus } from './domain';
|
|
3
|
+
import type { BoardStatus, AgentSession, CommandInfo, FileContent, FileEntry, Project, ProjectAttention, ReviewPullRequest, Task, TaskWorkspaceInfo, WritableBoardStatus } from './domain';
|
|
4
4
|
export type SupportedOpenForgeApiVersion = 1;
|
|
5
5
|
export declare const SUPPORTED_OPENFORGE_API_VERSIONS: readonly [1, ...1[]];
|
|
6
6
|
export declare const OPENFORGE_PLUGIN_API_VERSION: SupportedOpenForgeApiVersion;
|
|
@@ -17,7 +17,7 @@ export interface ValidationError {
|
|
|
17
17
|
path: string;
|
|
18
18
|
message: string;
|
|
19
19
|
}
|
|
20
|
-
declare const OPENFORGE_PLUGIN_CAPABILITY_TYPE_MEMBERS: readonly ['commands', 'events', 'views', 'injectionPoints', 'taskPane', 'taskStart', 'settings', 'background', 'backend', 'storage', 'context', 'navigation', 'tasks', 'projects', 'fs', 'shell', 'notifications', 'attention', 'system.openUrl', 'system.writeClipboardText', 'config', 'projectConfig', 'browserSurfaces', '
|
|
20
|
+
declare const OPENFORGE_PLUGIN_CAPABILITY_TYPE_MEMBERS: readonly ['commands', 'events', 'views', 'injectionPoints', 'taskPane', 'taskStart', 'settings', 'background', 'backend', 'storage', 'context', 'navigation', 'tasks', 'projects', 'fs', 'shell', 'notifications', 'attention', 'system.openUrl', 'system.writeClipboardText', 'config', 'projectConfig', 'browserSurfaces', 'appEnablement', 'customSidebarNavigation', 'reviewUI'];
|
|
21
21
|
export type OpenForgePluginCapability = (typeof OPENFORGE_PLUGIN_CAPABILITY_TYPE_MEMBERS)[number];
|
|
22
22
|
export interface OpenForgePackageMetadata {
|
|
23
23
|
id: string;
|
|
@@ -74,16 +74,6 @@ export interface NavigationAPI {
|
|
|
74
74
|
get(): OpenForgeNavigationSnapshot;
|
|
75
75
|
navigate(request: OpenForgeNavigationRequest): Promise<OpenForgeNavigationSnapshot>;
|
|
76
76
|
}
|
|
77
|
-
export interface TaskLinkOpenRequest {
|
|
78
|
-
taskId: string;
|
|
79
|
-
url: string;
|
|
80
|
-
}
|
|
81
|
-
export type TaskLinkHandlerResult = 'handled' | 'declined';
|
|
82
|
-
export type TaskLinkHandler = (request: TaskLinkOpenRequest) => Promise<TaskLinkHandlerResult>;
|
|
83
|
-
export interface TaskLinksAPI {
|
|
84
|
-
open(request: TaskLinkOpenRequest): Promise<void>;
|
|
85
|
-
registerHandler(handler: TaskLinkHandler): Disposable;
|
|
86
|
-
}
|
|
87
77
|
export interface OpenForgePluginContext {
|
|
88
78
|
pluginId: string;
|
|
89
79
|
apiVersion: SupportedOpenForgeApiVersion;
|
|
@@ -280,9 +270,34 @@ export interface PluginSettingsSectionRegistration {
|
|
|
280
270
|
scope?: PluginSettingsSectionScope;
|
|
281
271
|
component: PluginComponentLoader<PluginSettingsSectionProps> | PluginComponent<PluginSettingsSectionProps>;
|
|
282
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* Props a review-row action receives. `pr` is the review-requested pull request whose row
|
|
275
|
+
* is being rendered, and `projectId` is the local project that owns its repo (null when the
|
|
276
|
+
* host surface has none for it). The same contribution renders once per row, so the host
|
|
277
|
+
* remounts it with a different `pr` rather than handing over the whole list.
|
|
278
|
+
*/
|
|
279
|
+
export interface PluginReviewRowActionProps extends Record<string, unknown> {
|
|
280
|
+
api: FrontendOpenForgeAPI;
|
|
281
|
+
context: OpenForgeContextSnapshot;
|
|
282
|
+
pr: ReviewPullRequest;
|
|
283
|
+
projectId: string | null;
|
|
284
|
+
}
|
|
285
|
+
export interface PluginReviewRowActionRegistration {
|
|
286
|
+
id: string;
|
|
287
|
+
order?: number;
|
|
288
|
+
component: PluginComponentLoader<PluginReviewRowActionProps> | PluginComponent<PluginReviewRowActionProps>;
|
|
289
|
+
}
|
|
283
290
|
export interface FrontendViewRegistry {
|
|
284
291
|
register(registration: PluginViewRegistration): Disposable;
|
|
285
292
|
}
|
|
293
|
+
export interface FrontendReviewUIRegistry {
|
|
294
|
+
/**
|
|
295
|
+
* Contribute a control onto every review-requested pull-request row a host surface shows
|
|
296
|
+
* (today the attention overview). Rows are narrow and there is one per pull request, so
|
|
297
|
+
* keep the component to a chip or a single button and let it fetch its own state.
|
|
298
|
+
*/
|
|
299
|
+
registerRowAction(registration: PluginReviewRowActionRegistration): Disposable;
|
|
300
|
+
}
|
|
286
301
|
export type InjectionPointLocation = 'createTaskPrompt' | 'agentSession' | 'backlogPrompt';
|
|
287
302
|
export interface PluginInjectionPointProps extends Record<string, unknown> {
|
|
288
303
|
api: FrontendOpenForgeAPI;
|
|
@@ -384,6 +399,9 @@ export interface UserDataFileRequest {
|
|
|
384
399
|
export interface UserDataFileWriteRequest extends UserDataFileRequest {
|
|
385
400
|
content: string;
|
|
386
401
|
}
|
|
402
|
+
export interface UserDataFileAppendResult {
|
|
403
|
+
sizeBytes: number;
|
|
404
|
+
}
|
|
387
405
|
export interface ExternalReadDirectoryRequest {
|
|
388
406
|
root: string;
|
|
389
407
|
path?: string | null;
|
|
@@ -392,6 +410,12 @@ export interface ExternalReadFileRequest {
|
|
|
392
410
|
root: string;
|
|
393
411
|
path: string;
|
|
394
412
|
}
|
|
413
|
+
export interface ExternalFileMetadata {
|
|
414
|
+
/** Stable while the same filesystem object is appended in place. */
|
|
415
|
+
identity: string;
|
|
416
|
+
sizeBytes: number;
|
|
417
|
+
modifiedAtMs: number | null;
|
|
418
|
+
}
|
|
395
419
|
export declare const DEFAULT_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES: number;
|
|
396
420
|
export declare const MIN_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES = 4;
|
|
397
421
|
export declare const MAX_EXTERNAL_TEXT_FILE_CHUNK_SIZE_BYTES: number;
|
|
@@ -400,17 +424,27 @@ export declare function resolveExternalTextFileChunkSize(chunkSizeBytes?: number
|
|
|
400
424
|
export interface ExternalReadTextFileChunksRequest extends ExternalReadFileRequest {
|
|
401
425
|
/** UTF-8 chunks contain at most this many bytes. Defaults to 64 KiB. */
|
|
402
426
|
chunkSizeBytes?: number;
|
|
427
|
+
/** First byte to read. Must be a UTF-8 code point boundary. Defaults to zero. */
|
|
428
|
+
startOffsetBytes?: number;
|
|
429
|
+
/** Maximum total bytes to read. The range end must be a UTF-8 code point boundary. */
|
|
430
|
+
maxBytes?: number;
|
|
431
|
+
/** Fails the read if the file no longer has this identity. */
|
|
432
|
+
expectedIdentity?: string;
|
|
403
433
|
/** Stops future reads. An in-flight host read may finish, but its result is discarded. */
|
|
404
434
|
signal?: AbortSignal;
|
|
405
435
|
}
|
|
406
436
|
export interface UserDataFileSystemAPI {
|
|
407
437
|
readDir(request?: UserDataDirectoryRequest): Promise<FileEntry[]>;
|
|
408
438
|
readTextFile(request: UserDataFileRequest): Promise<string>;
|
|
439
|
+
/** Atomically replaces the file and syncs its contents before resolving. */
|
|
409
440
|
writeTextFile(request: UserDataFileWriteRequest): Promise<void>;
|
|
441
|
+
/** Appends and syncs content before returning the resulting UTF-8 byte size. */
|
|
442
|
+
appendTextFile(request: UserDataFileWriteRequest): Promise<UserDataFileAppendResult>;
|
|
410
443
|
}
|
|
411
444
|
export interface ExternalReadFileSystemAPI {
|
|
412
445
|
readDir(request: ExternalReadDirectoryRequest): Promise<FileEntry[]>;
|
|
413
446
|
readTextFile(request: ExternalReadFileRequest): Promise<string>;
|
|
447
|
+
stat(request: ExternalReadFileRequest): Promise<ExternalFileMetadata>;
|
|
414
448
|
/** Lazily reads a UTF-8 file without retaining a host file handle between chunks. */
|
|
415
449
|
readTextFileChunks(request: ExternalReadTextFileChunksRequest): AsyncIterable<string>;
|
|
416
450
|
}
|
|
@@ -432,10 +466,6 @@ export interface ShellSpawnRequest extends ShellSessionRequest {
|
|
|
432
466
|
export interface ShellWriteRequest extends ShellSessionRequest {
|
|
433
467
|
data: string;
|
|
434
468
|
}
|
|
435
|
-
export interface ShellTerminalQueryResponseRequest extends ShellSessionRequest {
|
|
436
|
-
ptyInstanceId: number;
|
|
437
|
-
data: string;
|
|
438
|
-
}
|
|
439
469
|
export interface ShellResizeRequest extends ShellSessionRequest {
|
|
440
470
|
cols: number;
|
|
441
471
|
rows: number;
|
|
@@ -444,9 +474,9 @@ export interface TerminalViewSnapshot {
|
|
|
444
474
|
instanceId: number;
|
|
445
475
|
watermark: number;
|
|
446
476
|
data: string;
|
|
477
|
+
compatibilityData?: string;
|
|
447
478
|
}
|
|
448
479
|
export interface PtyBufferState {
|
|
449
|
-
authority?: 'xterm-authoritative' | 'ghostty-authoritative';
|
|
450
480
|
buffer: string | null;
|
|
451
481
|
snapshot?: TerminalViewSnapshot | null;
|
|
452
482
|
isLive: boolean;
|
|
@@ -455,7 +485,6 @@ export interface PtyBufferState {
|
|
|
455
485
|
export interface ShellAPI {
|
|
456
486
|
spawn(request: ShellSpawnRequest): Promise<number>;
|
|
457
487
|
write(request: ShellWriteRequest): Promise<void>;
|
|
458
|
-
writeTerminalQueryResponse(request: ShellTerminalQueryResponseRequest): Promise<void>;
|
|
459
488
|
resize(request: ShellResizeRequest): Promise<void>;
|
|
460
489
|
kill(request: ShellSessionRequest): Promise<void>;
|
|
461
490
|
getBuffer(request: ShellSessionRequest): Promise<PtyBufferState>;
|
|
@@ -517,6 +546,61 @@ export declare class TaskFollowUpError extends Error {
|
|
|
517
546
|
readonly code: TaskFollowUpErrorCode;
|
|
518
547
|
constructor(code: TaskFollowUpErrorCode, message: string);
|
|
519
548
|
}
|
|
549
|
+
export interface ListTaskSessionsRequest {
|
|
550
|
+
taskId: string;
|
|
551
|
+
/** Inclusive Unix timestamp in seconds. Omit to return the Task's full Agent Session history. */
|
|
552
|
+
createdAtOrAfter?: number;
|
|
553
|
+
/** Open-ended provider identifier such as `pi`. Omit to include every provider. */
|
|
554
|
+
provider?: string;
|
|
555
|
+
}
|
|
556
|
+
export declare const MAX_AGENT_SESSION_PAGE_SIZE = 250;
|
|
557
|
+
export type AgentSessionCursor = string;
|
|
558
|
+
export interface AgentSessionOverlap {
|
|
559
|
+
/** Inclusive lower bound as a Unix timestamp in seconds. */
|
|
560
|
+
startInclusive: number;
|
|
561
|
+
/** Exclusive upper bound as a Unix timestamp in seconds. */
|
|
562
|
+
endExclusive: number;
|
|
563
|
+
}
|
|
564
|
+
export interface ListAgentSessionsRequest {
|
|
565
|
+
/** Open-ended provider identifier such as `pi`. */
|
|
566
|
+
provider: string;
|
|
567
|
+
overlaps: AgentSessionOverlap;
|
|
568
|
+
/** Restrict the query to one Task without enumerating unrelated Tasks. */
|
|
569
|
+
taskId?: string;
|
|
570
|
+
/** Opaque cursor returned by the preceding page. */
|
|
571
|
+
cursor?: AgentSessionCursor;
|
|
572
|
+
/** Number of Agent Sessions to return, from 1 through 250. */
|
|
573
|
+
pageSize: number;
|
|
574
|
+
}
|
|
575
|
+
export interface AgentSessionTaskSummary {
|
|
576
|
+
id: string;
|
|
577
|
+
title: string;
|
|
578
|
+
status: BoardStatus;
|
|
579
|
+
createdAt: number;
|
|
580
|
+
updatedAt: number;
|
|
581
|
+
}
|
|
582
|
+
export interface AgentSessionWorkspace {
|
|
583
|
+
rootPath: string;
|
|
584
|
+
kind: 'project' | 'worktree';
|
|
585
|
+
}
|
|
586
|
+
export interface AgentSessionSummary {
|
|
587
|
+
/** OpenForge Agent Session ID. */
|
|
588
|
+
id: string;
|
|
589
|
+
provider: string;
|
|
590
|
+
/** Provider-owned Agent Session ID, or null when the host has none. */
|
|
591
|
+
providerSessionId: string | null;
|
|
592
|
+
createdAt: number;
|
|
593
|
+
updatedAt: number;
|
|
594
|
+
task: AgentSessionTaskSummary;
|
|
595
|
+
workspace: AgentSessionWorkspace | null;
|
|
596
|
+
}
|
|
597
|
+
export interface AgentSessionSummaryPage {
|
|
598
|
+
items: AgentSessionSummary[];
|
|
599
|
+
nextCursor: AgentSessionCursor | null;
|
|
600
|
+
}
|
|
601
|
+
export interface AgentSessionsAPI {
|
|
602
|
+
list(request: ListAgentSessionsRequest): Promise<AgentSessionSummaryPage>;
|
|
603
|
+
}
|
|
520
604
|
export interface TasksAPI {
|
|
521
605
|
/**
|
|
522
606
|
* Lists tasks, optionally scoped to a project. By default done tasks are
|
|
@@ -545,6 +629,8 @@ export interface TasksAPI {
|
|
|
545
629
|
sendFollowUp(request: SendTaskFollowUpRequest): Promise<TaskFollowUpReceipt>;
|
|
546
630
|
getWorkspace(taskId: string): Promise<TaskWorkspaceInfo | null>;
|
|
547
631
|
getLatestSession(taskId: string): Promise<AgentSession | null>;
|
|
632
|
+
/** Returns matching Agent Sessions newest first. */
|
|
633
|
+
listSessions(request: ListTaskSessionsRequest): Promise<AgentSession[]>;
|
|
548
634
|
}
|
|
549
635
|
export interface ProjectsAPI {
|
|
550
636
|
list(): Promise<Project[]>;
|
|
@@ -576,6 +662,7 @@ export interface OpenForgeCommonAPI {
|
|
|
576
662
|
context: {
|
|
577
663
|
getSnapshot(): OpenForgeContextSnapshot;
|
|
578
664
|
};
|
|
665
|
+
agentSessions: AgentSessionsAPI;
|
|
579
666
|
tasks: TasksAPI;
|
|
580
667
|
projects: ProjectsAPI;
|
|
581
668
|
fs: FileSystemAPI;
|
|
@@ -588,10 +675,10 @@ export interface OpenForgeCommonAPI {
|
|
|
588
675
|
}
|
|
589
676
|
export interface FrontendOpenForgeAPI extends OpenForgeCommonAPI {
|
|
590
677
|
browserSurfaces: BrowserSurfacesAPI;
|
|
591
|
-
taskLinks: TaskLinksAPI;
|
|
592
678
|
navigation: NavigationAPI;
|
|
593
679
|
views: FrontendViewRegistry;
|
|
594
680
|
taskUI: FrontendTaskUIRegistry;
|
|
681
|
+
reviewUI: FrontendReviewUIRegistry;
|
|
595
682
|
/** @deprecated Use `taskUI.registerTab(...)`. */
|
|
596
683
|
taskPane: FrontendTaskPaneRegistry;
|
|
597
684
|
settings: FrontendSettingsRegistry;
|
package/dist/types.js
CHANGED
|
@@ -34,9 +34,9 @@ const OPENFORGE_PLUGIN_CAPABILITY_TYPE_MEMBERS = [
|
|
|
34
34
|
'config',
|
|
35
35
|
'projectConfig',
|
|
36
36
|
'browserSurfaces',
|
|
37
|
-
'taskLinks',
|
|
38
37
|
'appEnablement',
|
|
39
38
|
'customSidebarNavigation',
|
|
39
|
+
'reviewUI',
|
|
40
40
|
];
|
|
41
41
|
function assertOpenForgePluginCapabilitiesMatchSchema() {
|
|
42
42
|
const schemaCapabilities = packageMetadataSchemaData.properties.requires.items.enum;
|
|
@@ -70,6 +70,7 @@ export class TaskFollowUpError extends Error {
|
|
|
70
70
|
this.code = code;
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
+
export const MAX_AGENT_SESSION_PAGE_SIZE = 250;
|
|
73
74
|
export function makePluginViewKey(pluginId, viewId) {
|
|
74
75
|
return `plugin:${pluginId}:${viewId}`;
|
|
75
76
|
}
|
package/dist/ui/Modal.svelte
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
export type ModalInitialFocus = HTMLElement | string | (() => HTMLElement | null | undefined) | null | undefined
|
|
6
6
|
|
|
7
|
+
type ModalAccessibleName =
|
|
8
|
+
| { ariaLabel: string; ariaLabelledby?: never }
|
|
9
|
+
| { ariaLabel?: never; ariaLabelledby: string }
|
|
10
|
+
|
|
7
11
|
interface Props {
|
|
8
12
|
onClose: () => void
|
|
9
13
|
maxWidth?: string
|
|
10
14
|
overflowVisible?: boolean
|
|
11
15
|
initialFocus?: ModalInitialFocus
|
|
12
|
-
ariaLabel?: string
|
|
13
16
|
showHeader?: boolean
|
|
14
17
|
closeLabel?: string
|
|
15
18
|
closeDisabled?: boolean
|
|
@@ -21,7 +24,34 @@
|
|
|
21
24
|
children: Snippet
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
let { onClose, maxWidth = '500px', overflowVisible = false, initialFocus, ariaLabel, showHeader = true, closeLabel = 'Close dialog', closeDisabled = false, onKeydown, testId, modalClass = '', boxClass = '', header, children }: Props = $props()
|
|
27
|
+
let { onClose, maxWidth = '500px', overflowVisible = false, initialFocus, ariaLabel, ariaLabelledby, showHeader = true, closeLabel = 'Close dialog', closeDisabled = false, onKeydown, testId, modalClass = '', boxClass = '', header, children }: Props & ModalAccessibleName = $props()
|
|
28
|
+
let accessibleNameAttributes = $derived.by(() => {
|
|
29
|
+
const hasAriaLabel = Boolean(ariaLabel?.trim())
|
|
30
|
+
const hasAriaLabelledby = Boolean(ariaLabelledby?.trim())
|
|
31
|
+
|
|
32
|
+
if (hasAriaLabel === hasAriaLabelledby) {
|
|
33
|
+
throw new Error('Modal requires exactly one non-empty accessible name prop: ariaLabel or ariaLabelledby')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
ariaLabel: hasAriaLabel ? ariaLabel : undefined,
|
|
38
|
+
ariaLabelledby: hasAriaLabelledby ? ariaLabelledby : undefined,
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
$effect(() => {
|
|
43
|
+
const idReferences = accessibleNameAttributes.ariaLabelledby?.split(/\s+/)
|
|
44
|
+
if (!idReferences) return
|
|
45
|
+
|
|
46
|
+
const hasNamingText = idReferences.some((id) => {
|
|
47
|
+
const labelledElement = document.getElementById(id)
|
|
48
|
+
return Boolean(labelledElement?.textContent?.trim() || labelledElement?.getAttribute('aria-label')?.trim())
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
if (!hasNamingText) {
|
|
52
|
+
throw new Error('Modal ariaLabelledby must reference at least one element with naming text')
|
|
53
|
+
}
|
|
54
|
+
})
|
|
25
55
|
|
|
26
56
|
let modalElement: HTMLDivElement | null = $state(null)
|
|
27
57
|
let hasAppliedInitialFocus = false
|
|
@@ -142,7 +172,7 @@
|
|
|
142
172
|
}
|
|
143
173
|
</script>
|
|
144
174
|
|
|
145
|
-
<div bind:this={modalElement} class="modal modal-open {modalClass}" data-testid={testId} onclick={handleOverlayClick} onkeydown={handleKeydown} role="dialog" aria-modal="true" aria-label={ariaLabel} tabindex="-1">
|
|
175
|
+
<div bind:this={modalElement} class="modal modal-open {modalClass}" data-testid={testId} onclick={handleOverlayClick} onkeydown={handleKeydown} role="dialog" aria-modal="true" aria-label={accessibleNameAttributes.ariaLabel} aria-labelledby={accessibleNameAttributes.ariaLabelledby} tabindex="-1">
|
|
146
176
|
<div class="modal-box bg-base-100 shadow-xl p-0 flex flex-col max-h-[90vh] {overflowVisible ? 'overflow-visible' : ''} {boxClass}" style="max-width: {maxWidth}">
|
|
147
177
|
{#if showHeader}
|
|
148
178
|
<div class="flex items-center justify-between px-5 py-4 border-b border-base-300">
|
|
@@ -5,17 +5,24 @@
|
|
|
5
5
|
title: string
|
|
6
6
|
subtitle?: string | null
|
|
7
7
|
surface?: 'default' | 'subtle'
|
|
8
|
+
headingLevel?: 'h1' | 'h2'
|
|
8
9
|
actions?: Snippet
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
let {
|
|
12
|
+
let {
|
|
13
|
+
title,
|
|
14
|
+
subtitle = null,
|
|
15
|
+
surface = 'default',
|
|
16
|
+
headingLevel = 'h1',
|
|
17
|
+
actions,
|
|
18
|
+
}: Props = $props()
|
|
12
19
|
</script>
|
|
13
20
|
|
|
14
|
-
<header class="flex items-center justify-between gap-4 border-b border-base-300 px-
|
|
21
|
+
<header class="flex min-h-13 shrink-0 flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b border-base-300 px-4 py-2 sm:px-6 {surface === 'default' ? 'bg-base-100' : 'bg-base-200'}">
|
|
15
22
|
<div class="min-w-0">
|
|
16
|
-
<
|
|
23
|
+
<svelte:element this={headingLevel} class="m-0 truncate text-base font-semibold leading-5 tracking-[-0.01em] text-base-content">{title}</svelte:element>
|
|
17
24
|
{#if subtitle}
|
|
18
|
-
<p class="text-[13px]
|
|
25
|
+
<p class="m-0 mt-0.5 truncate text-[13px] leading-5 text-base-content/60">{subtitle}</p>
|
|
19
26
|
{/if}
|
|
20
27
|
</div>
|
|
21
28
|
{#if actions}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte'
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
header: Snippet
|
|
6
|
+
children: Snippet
|
|
7
|
+
class?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let { header, children, class: className = '' }: Props = $props()
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<div class="flex h-full min-h-0 flex-col overflow-hidden bg-base-100 text-base-content {className}">
|
|
14
|
+
<div class="shrink-0">
|
|
15
|
+
{@render header()}
|
|
16
|
+
</div>
|
|
17
|
+
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
|
18
|
+
{@render children()}
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tick } from 'svelte'
|
|
3
|
+
import FileTypeIcon from './FileTypeIcon.svelte'
|
|
4
|
+
import {
|
|
5
|
+
buildProjectFileTree,
|
|
6
|
+
flattenVisibleProjectFileTree,
|
|
7
|
+
formatProjectFileTreeSize,
|
|
8
|
+
getProjectFileTreeDepth,
|
|
9
|
+
getProjectFileTreeItemAccessibility,
|
|
10
|
+
getProjectFileTreeKeyboardAction,
|
|
11
|
+
projectFileTreePathToId,
|
|
12
|
+
type ProjectFileTreeNode,
|
|
13
|
+
} from '../projectFileTree'
|
|
14
|
+
import type { FileEntry } from '../domain'
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
entries: FileEntry[]
|
|
18
|
+
expandedDirs: Set<string>
|
|
19
|
+
selectedPath: string | null
|
|
20
|
+
onToggleDir: (path: string) => void
|
|
21
|
+
onSelectFile: (path: string) => void
|
|
22
|
+
initialScrollTop?: number
|
|
23
|
+
onScrollTopChange?: (scrollTop: number) => void
|
|
24
|
+
focusSelectedRequest?: number | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type TreeNode = ProjectFileTreeNode<FileEntry>
|
|
28
|
+
|
|
29
|
+
const {
|
|
30
|
+
entries,
|
|
31
|
+
expandedDirs,
|
|
32
|
+
selectedPath,
|
|
33
|
+
onToggleDir,
|
|
34
|
+
onSelectFile,
|
|
35
|
+
initialScrollTop = 0,
|
|
36
|
+
onScrollTopChange,
|
|
37
|
+
focusSelectedRequest = null,
|
|
38
|
+
}: Props = $props()
|
|
39
|
+
|
|
40
|
+
let scrollContainer = $state<HTMLDivElement | null>(null)
|
|
41
|
+
let appliedInitialScrollTop = $state<number | null>(null)
|
|
42
|
+
let focusedPath = $state<string | null>(null)
|
|
43
|
+
let lastSelectedPath = $state<string | null>(null)
|
|
44
|
+
let appliedFocusSelectedRequest = $state<number | null>(null)
|
|
45
|
+
|
|
46
|
+
const treeNodes = $derived(buildProjectFileTree(entries))
|
|
47
|
+
const visibleNodes = $derived(flattenVisibleProjectFileTree(treeNodes, expandedDirs))
|
|
48
|
+
const visiblePaths = $derived(visibleNodes.map((node) => node.entry.path))
|
|
49
|
+
|
|
50
|
+
function getTreeItemElement(path: string): HTMLElement | null {
|
|
51
|
+
const index = visiblePaths.indexOf(path)
|
|
52
|
+
if (index === -1) return null
|
|
53
|
+
return scrollContainer?.querySelector<HTMLElement>(`[data-tree-index="${index}"]`) ?? null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function focusPath(path: string) {
|
|
57
|
+
focusedPath = path
|
|
58
|
+
await tick()
|
|
59
|
+
getTreeItemElement(path)?.focus()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function activateNode(node: TreeNode) {
|
|
63
|
+
if (node.entry.isDir) {
|
|
64
|
+
void focusPath(node.entry.path)
|
|
65
|
+
onToggleDir(node.entry.path)
|
|
66
|
+
} else {
|
|
67
|
+
onSelectFile(node.entry.path)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function handleKeydown(event: KeyboardEvent, node: TreeNode) {
|
|
72
|
+
const action = getProjectFileTreeKeyboardAction(event, node, { expandedDirs, visiblePaths })
|
|
73
|
+
if (!action.handled) return
|
|
74
|
+
|
|
75
|
+
event.preventDefault()
|
|
76
|
+
event.stopPropagation()
|
|
77
|
+
|
|
78
|
+
switch (action.type) {
|
|
79
|
+
case 'activate':
|
|
80
|
+
activateNode(node)
|
|
81
|
+
break
|
|
82
|
+
case 'focus':
|
|
83
|
+
void focusPath(action.path)
|
|
84
|
+
break
|
|
85
|
+
case 'toggle':
|
|
86
|
+
onToggleDir(action.path)
|
|
87
|
+
break
|
|
88
|
+
case 'none':
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function handleScroll() {
|
|
94
|
+
if (scrollContainer) {
|
|
95
|
+
onScrollTopChange?.(scrollContainer.scrollTop)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
$effect(() => {
|
|
100
|
+
if (scrollContainer && appliedInitialScrollTop !== initialScrollTop) {
|
|
101
|
+
scrollContainer.scrollTop = initialScrollTop
|
|
102
|
+
appliedInitialScrollTop = initialScrollTop
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
$effect(() => {
|
|
107
|
+
const selectedChanged = selectedPath !== lastSelectedPath
|
|
108
|
+
|
|
109
|
+
if (selectedChanged && selectedPath !== null && visiblePaths.includes(selectedPath)) {
|
|
110
|
+
focusedPath = selectedPath
|
|
111
|
+
} else if (focusedPath === null || !visiblePaths.includes(focusedPath)) {
|
|
112
|
+
focusedPath = selectedPath !== null && visiblePaths.includes(selectedPath) ? selectedPath : visiblePaths[0] ?? null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
lastSelectedPath = selectedPath
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
$effect(() => {
|
|
119
|
+
if (focusSelectedRequest === null || appliedFocusSelectedRequest === focusSelectedRequest) return
|
|
120
|
+
appliedFocusSelectedRequest = focusSelectedRequest
|
|
121
|
+
if (selectedPath !== null && visiblePaths.includes(selectedPath)) {
|
|
122
|
+
void focusPath(selectedPath)
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
</script>
|
|
126
|
+
|
|
127
|
+
<div class="flex h-full flex-col border-r border-base-300 bg-base-100">
|
|
128
|
+
<div
|
|
129
|
+
class="flex-1 overflow-y-auto py-2"
|
|
130
|
+
bind:this={scrollContainer}
|
|
131
|
+
onscroll={handleScroll}
|
|
132
|
+
role="tree"
|
|
133
|
+
aria-label="Project files"
|
|
134
|
+
>
|
|
135
|
+
{#snippet renderNodes(nodes: TreeNode[])}
|
|
136
|
+
{#each nodes as node (node.entry.path)}
|
|
137
|
+
{@const entry = node.entry}
|
|
138
|
+
{@const isExpanded = expandedDirs.has(entry.path)}
|
|
139
|
+
{@const isSelected = selectedPath === entry.path}
|
|
140
|
+
{@const treeIndex = visiblePaths.indexOf(entry.path)}
|
|
141
|
+
{@const labelId = `${projectFileTreePathToId(entry.path)}-label`}
|
|
142
|
+
{@const sizeId = `${projectFileTreePathToId(entry.path)}-size`}
|
|
143
|
+
{@const a11y = getProjectFileTreeItemAccessibility(node, { expandedDirs, selectedPath, labelId, sizeId })}
|
|
144
|
+
<div
|
|
145
|
+
class="outline-none [&:focus-visible>div:first-child]:ring-2 [&:focus-visible>div:first-child]:ring-inset [&:focus-visible>div:first-child]:ring-primary/60"
|
|
146
|
+
role="treeitem"
|
|
147
|
+
tabindex={focusedPath === entry.path ? 0 : -1}
|
|
148
|
+
aria-level={a11y.level}
|
|
149
|
+
aria-setsize={a11y.setSize}
|
|
150
|
+
aria-posinset={a11y.posInSet}
|
|
151
|
+
aria-expanded={a11y.expanded}
|
|
152
|
+
aria-current={a11y.current}
|
|
153
|
+
aria-selected={a11y.selected}
|
|
154
|
+
aria-labelledby={a11y.labelledBy}
|
|
155
|
+
data-testid="tree-entry"
|
|
156
|
+
data-tree-index={treeIndex}
|
|
157
|
+
onclick={(event) => {
|
|
158
|
+
event.stopPropagation()
|
|
159
|
+
activateNode(node)
|
|
160
|
+
}}
|
|
161
|
+
onkeydown={(event) => handleKeydown(event, node)}
|
|
162
|
+
onfocus={() => {
|
|
163
|
+
focusedPath = entry.path
|
|
164
|
+
}}
|
|
165
|
+
>
|
|
166
|
+
<div
|
|
167
|
+
class="w-full flex items-center gap-2 text-xs cursor-pointer transition-colors py-1.5 pr-3 {entry.isDir ? 'text-base-content hover:bg-base-content/5' : isSelected ? 'bg-primary/10 text-primary font-medium border-l-2 border-l-primary hover:bg-primary/15' : 'text-base-content hover:bg-base-content/5'}"
|
|
168
|
+
style="padding-left: {entry.isDir || !isSelected ? 12 + getProjectFileTreeDepth(entry.path) * 16 : 10 + getProjectFileTreeDepth(entry.path) * 16}px"
|
|
169
|
+
>
|
|
170
|
+
{#if entry.isDir}
|
|
171
|
+
<span class="text-[0.6rem] text-base-content/50 shrink-0" data-testid={`dir-indicator-${entry.path}`} aria-hidden="true">{isExpanded ? '▼' : '▶'}</span>
|
|
172
|
+
<FileTypeIcon folder open={isExpanded} class="w-3.5 h-3.5" />
|
|
173
|
+
<span id={labelId} class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-left" data-testid="entry-label">{entry.name}/</span>
|
|
174
|
+
{:else}
|
|
175
|
+
<FileTypeIcon filename={entry.path} class="w-3.5 h-3.5" />
|
|
176
|
+
<span id={labelId} class="flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-left" data-testid="entry-label">{entry.name}</span>
|
|
177
|
+
<span id={sizeId} class="text-base-content/50 text-[0.7rem] ml-auto">{formatProjectFileTreeSize(entry.size)}</span>
|
|
178
|
+
{/if}
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{#if entry.isDir && isExpanded && node.children.length > 0}
|
|
182
|
+
<div role="group">
|
|
183
|
+
{@render renderNodes(node.children)}
|
|
184
|
+
</div>
|
|
185
|
+
{/if}
|
|
186
|
+
</div>
|
|
187
|
+
{/each}
|
|
188
|
+
{/snippet}
|
|
189
|
+
|
|
190
|
+
{@render renderNodes(treeNodes)}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
package/dist/vite.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* plugin://host-runtime assets are derived from the same host-runtime contract.
|
|
5
5
|
*/
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
|
-
import {
|
|
7
|
+
import { OPENFORGE_PLUGIN_SDK_PUBLIC_ENTRYPOINTS } from './publicEntrypoints.mjs';
|
|
8
8
|
import { OPENFORGE_HOST_RUNTIME_SVELTE_SPECIFIERS as HOST_RUNTIME_SVELTE_SPECIFIERS } from './svelteHostRuntimeContract.mjs';
|
|
9
9
|
export const OPENFORGE_HOST_RUNTIME_SVELTE_SPECIFIERS = HOST_RUNTIME_SVELTE_SPECIFIERS;
|
|
10
10
|
export const OPENFORGE_HOST_SHARED_SVELTE_IMPORTS = OPENFORGE_HOST_RUNTIME_SVELTE_SPECIFIERS;
|
|
@@ -26,22 +26,12 @@ export function isOpenForgeHostRuntimeExternal(id) {
|
|
|
26
26
|
}
|
|
27
27
|
export const openforgePluginViteExternals = isOpenForgeHostRuntimeExternal;
|
|
28
28
|
const OPENFORGE_PLUGIN_SDK_SOURCE_ENTRYPOINTS = Object.freeze([
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
['@openforge-app/plugin-sdk/prStatusPresentation', 'packages/plugin-sdk/src/prStatusPresentation.ts'],
|
|
36
|
-
['@openforge-app/plugin-sdk/markdown', 'packages/plugin-sdk/src/markdown.ts'],
|
|
37
|
-
['@openforge-app/plugin-sdk/numberParsing', 'packages/plugin-sdk/src/numberParsing.ts'],
|
|
38
|
-
['@openforge-app/plugin-sdk/projectFileTree', 'packages/plugin-sdk/src/projectFileTree.ts'],
|
|
39
|
-
['@openforge-app/plugin-sdk/sanitize', 'packages/plugin-sdk/src/sanitize.ts'],
|
|
40
|
-
['@openforge-app/plugin-sdk/pluginIcons', 'packages/plugin-sdk/src/pluginIcons.ts'],
|
|
41
|
-
['@openforge-app/plugin-sdk/fileIcons', 'packages/plugin-sdk/src/fileIcons.ts'],
|
|
42
|
-
['@openforge-app/plugin-sdk/collapsibleSectionState', 'packages/plugin-sdk/src/collapsibleSectionState.ts'],
|
|
43
|
-
...OPENFORGE_PLUGIN_SDK_PUBLIC_UI_EXPORTS.map(({ importSpecifier, workspaceSourcePath }) => [importSpecifier, workspaceSourcePath]),
|
|
44
|
-
['@openforge-app/plugin-sdk', 'packages/plugin-sdk/src/index.ts'],
|
|
29
|
+
...OPENFORGE_PLUGIN_SDK_PUBLIC_ENTRYPOINTS
|
|
30
|
+
.filter(({ packageSubpath }) => packageSubpath !== '.')
|
|
31
|
+
.map(({ importSpecifier, workspaceSourcePath }) => [importSpecifier, workspaceSourcePath]),
|
|
32
|
+
...OPENFORGE_PLUGIN_SDK_PUBLIC_ENTRYPOINTS
|
|
33
|
+
.filter(({ packageSubpath }) => packageSubpath === '.')
|
|
34
|
+
.map(({ importSpecifier, workspaceSourcePath }) => [importSpecifier, workspaceSourcePath]),
|
|
45
35
|
]);
|
|
46
36
|
function repoRootUrl(repoRoot) {
|
|
47
37
|
if (repoRoot instanceof URL) {
|