@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/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', 'taskLinks', 'appEnablement', 'customSidebarNavigation'];
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
  }
@@ -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 { title, subtitle = null, surface = 'subtle', actions }: Props = $props()
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-6 py-3 shrink-0 {surface === 'default' ? 'bg-base-100' : 'bg-base-200'}">
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
- <h2 class="text-[22px] font-semibold text-base-content tracking-tight m-0 truncate">{title}</h2>
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] text-secondary mt-0.5 m-0 truncate">{subtitle}</p>
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 { OPENFORGE_PLUGIN_SDK_PUBLIC_UI_EXPORTS } from './publicUiExports.mjs';
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
- ['@openforge-app/plugin-sdk/frontend', 'packages/plugin-sdk/src/frontend.ts'],
30
- ['@openforge-app/plugin-sdk/backend', 'packages/plugin-sdk/src/backend.ts'],
31
- ['@openforge-app/plugin-sdk/testing', 'packages/plugin-sdk/src/testing.ts'],
32
- ['@openforge-app/plugin-sdk/vite', 'packages/plugin-sdk/src/vite.ts'],
33
- ['@openforge-app/plugin-sdk/package-metadata-schema.json', 'packages/plugin-sdk/src/openforgePackageMetadataSchema.json'],
34
- ['@openforge-app/plugin-sdk/domain', 'packages/plugin-sdk/src/domain.ts'],
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) {