@adecore/editor-react 0.23.0 → 0.23.1

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.
@@ -7,6 +7,9 @@ import { fileUriToPath } from '@adecore/lsp';
7
7
  import { Icon, IconButton, Segmented, Spinner, Tooltip } from '@adecore/ui';
8
8
  import { formatNumber } from '@adecore/ui/format';
9
9
  import { CALL_HIERARCHY_HEIGHT } from './call-hierarchy.js';
10
+ import { PanelResizeHandle } from './PanelResizeHandle.js';
11
+ import { usePanelBody } from './use-panel-body.js';
12
+ import { PANEL_BODY_HEIGHT } from './panel-size.js';
10
13
  import { CALL_LEVEL_LIMIT } from './call-hierarchy-model.js';
11
14
  import { basenameOf, relativeTo } from './paths.js';
12
15
  import { PathText } from './PathText.js';
@@ -75,14 +78,15 @@ function StatusText({ row, direction }) {
75
78
  */
76
79
  export function CallHierarchyPanel({ language, view }) {
77
80
  const { t } = useTranslation('editor');
81
+ const body = usePanelBody(language);
78
82
  if (view.container === null) {
79
83
  return null;
80
84
  }
81
85
  const folder = language.project.folder;
82
86
  const path = view.preview === null ? null : fileUriToPath(view.preview.uri);
83
87
  const shown = path === null ? '' : folderOf(relativeTo(path, folder));
84
- return createPortal(_jsxs("div", { className: "flex flex-col bg-surface pt-[2px] font-sans text-xs text-text", style: { height: CALL_HIERARCHY_HEIGHT }, onPointerDown: (event) => event.preventDefault(), children: [_jsx("div", { className: "peek-hatch h-[14px]" }), _jsxs("div", { className: "flex h-[33px] shrink-0 items-center gap-2 border-b border-border pr-2 pl-3 text-sm", children: [_jsx(Segmented, { value: view.direction, onValueChange: (direction) => language.callHierarchy.setDirection(direction), label: t('language.callHierarchy.direction'), options: [
88
+ return createPortal(_jsxs("div", { className: "flex flex-col bg-surface pt-[2px] font-sans text-xs text-text", style: { height: CALL_HIERARCHY_HEIGHT - PANEL_BODY_HEIGHT + body.height }, onPointerDown: (event) => event.preventDefault(), children: [_jsx("div", { className: "peek-hatch h-[14px]" }), _jsxs("div", { className: "flex h-[33px] shrink-0 items-center gap-2 border-b border-border pr-2 pl-3 text-sm", children: [_jsx(Segmented, { value: view.direction, onValueChange: (direction) => language.callHierarchy.setDirection(direction), label: t('language.callHierarchy.direction'), options: [
85
89
  { id: 'incoming', label: t('language.callHierarchy.incoming') },
86
90
  { id: 'outgoing', label: t('language.callHierarchy.outgoing') }
87
- ] }), _jsx("span", { className: "shrink-0 font-medium", children: view.name }), _jsx("span", { className: "shrink-0 font-medium text-text-muted", children: path === null ? '' : basenameOf(path) }), _jsx(PathText, { path: shown, className: "text-text-muted" }), _jsxs("div", { className: "ml-auto flex shrink-0 items-center gap-1", children: [view.sites > 1 && (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: ChevronLeft, size: "xs", label: t('language.callHierarchy.previousSite'), onClick: () => language.callHierarchy.stepSite(-1) }), _jsx("span", { className: "text-text-muted", children: t('language.callHierarchy.site', { current: formatNumber(view.site + 1), total: formatNumber(view.sites) }) }), _jsx(IconButton, { icon: ChevronRight, size: "xs", label: t('language.callHierarchy.nextSite'), onClick: () => language.callHierarchy.stepSite(1) })] })), _jsx(IconButton, { icon: X, size: "xs", label: t('language.callHierarchy.close'), onClick: () => language.callHierarchy.close() })] })] }), _jsxs("div", { className: "flex h-[180px] shrink-0", children: [_jsx(Tree, { language: language, view: view }), _jsx(Preview, { preview: view.preview })] }), _jsx("div", { className: "peek-hatch h-[14px]" })] }), view.container);
91
+ ] }), _jsx("span", { className: "shrink-0 font-medium", children: view.name }), _jsx("span", { className: "shrink-0 font-medium text-text-muted", children: path === null ? '' : basenameOf(path) }), _jsx(PathText, { path: shown, className: "text-text-muted" }), _jsxs("div", { className: "ml-auto flex shrink-0 items-center gap-1", children: [view.sites > 1 && (_jsxs(_Fragment, { children: [_jsx(IconButton, { icon: ChevronLeft, size: "xs", label: t('language.callHierarchy.previousSite'), onClick: () => language.callHierarchy.stepSite(-1) }), _jsx("span", { className: "text-text-muted", children: t('language.callHierarchy.site', { current: formatNumber(view.site + 1), total: formatNumber(view.sites) }) }), _jsx(IconButton, { icon: ChevronRight, size: "xs", label: t('language.callHierarchy.nextSite'), onClick: () => language.callHierarchy.stepSite(1) })] })), _jsx(IconButton, { icon: X, size: "xs", label: t('language.callHierarchy.close'), onClick: () => language.callHierarchy.close() })] })] }), _jsxs("div", { className: "flex shrink-0", style: { height: body.height }, children: [_jsx(Tree, { language: language, view: view }), _jsx(Preview, { preview: view.preview })] }), _jsx("div", { className: "peek-hatch relative h-[14px]", children: _jsx(PanelResizeHandle, { container: view.container, height: body.height, onHeight: body.set, onCommit: body.commit }) })] }), view.container);
88
92
  }
@@ -0,0 +1,8 @@
1
+ interface Props {
2
+ container: HTMLElement;
3
+ height: number;
4
+ onHeight(height: number): void;
5
+ onCommit(height: number): void;
6
+ }
7
+ export declare function PanelResizeHandle({ container, height, onHeight, onCommit }: Props): import("react").JSX.Element;
8
+ export {};
@@ -0,0 +1,71 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import { PANEL_BODY_MIN, PANEL_KEY_STEP, availableHeightOf, clampPanelBody, panelBodyMax } from './panel-size.js';
5
+ import { realTimers, revealRow } from './timers.js';
6
+ /* The bottom edge of a panel: a drag, or the arrow keys, change the height of its body. */
7
+ export function PanelResizeHandle({ container, height, onHeight, onCommit }) {
8
+ const { t } = useTranslation('editor');
9
+ const drag = useRef(null);
10
+ const [dragging, setDragging] = useState(false);
11
+ const [max, setMax] = useState(() => panelBodyMax(availableHeightOf(container)));
12
+ useEffect(() => {
13
+ const viewport = container.closest('.se-viewport');
14
+ const Observer = viewport?.ownerDocument.defaultView?.ResizeObserver;
15
+ if (viewport === null || viewport === undefined || Observer === undefined) {
16
+ return;
17
+ }
18
+ const observer = new Observer(() => setMax(panelBodyMax(availableHeightOf(container))));
19
+ observer.observe(viewport);
20
+ return () => observer.disconnect();
21
+ }, [container]);
22
+ function settle(value, from) {
23
+ const next = clampPanelBody(value, availableHeightOf(container));
24
+ onHeight(next);
25
+ if (next !== from) {
26
+ onCommit(next);
27
+ revealRow(realTimers, container);
28
+ }
29
+ }
30
+ function heightAt(clientY) {
31
+ return clampPanelBody(drag.current.startHeight + clientY - drag.current.startY, availableHeightOf(container));
32
+ }
33
+ function down(event) {
34
+ if (event.button !== 0) {
35
+ return;
36
+ }
37
+ event.currentTarget.setPointerCapture(event.pointerId);
38
+ drag.current = { startY: event.clientY, startHeight: height, lastY: event.clientY };
39
+ setDragging(true);
40
+ }
41
+ function move(event) {
42
+ if (drag.current !== null) {
43
+ drag.current.lastY = event.clientY;
44
+ onHeight(heightAt(event.clientY));
45
+ }
46
+ }
47
+ // Also the end of a drag whose capture was lost, such as a container that was replaced under the pointer.
48
+ function end(event) {
49
+ if (drag.current === null) {
50
+ return;
51
+ }
52
+ const { startHeight } = drag.current;
53
+ const next = heightAt(drag.current.lastY);
54
+ drag.current = null;
55
+ setDragging(false);
56
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
57
+ settle(next, startHeight);
58
+ }
59
+ function key(event) {
60
+ const step = event.shiftKey ? PANEL_KEY_STEP * 4 : PANEL_KEY_STEP;
61
+ const end = panelBodyMax(availableHeightOf(container));
62
+ const target = { ArrowUp: height - step, ArrowDown: height + step, Home: PANEL_BODY_MIN, End: end }[event.key];
63
+ if (target === undefined) {
64
+ return;
65
+ }
66
+ event.preventDefault();
67
+ event.stopPropagation();
68
+ settle(target, height);
69
+ }
70
+ return (_jsx("div", { role: "separator", "aria-orientation": "horizontal", "aria-label": t('language.panel.resize'), "aria-valuemin": PANEL_BODY_MIN, "aria-valuemax": max, "aria-valuenow": height, tabIndex: 0, "data-dragging": dragging, className: "absolute inset-x-0 bottom-0 z-10 h-1 cursor-row-resize touch-none outline-none hover:bg-accent-soft focus-visible:bg-accent-soft data-[dragging=true]:bg-accent-soft", onPointerDown: down, onPointerMove: move, onPointerUp: end, onPointerCancel: end, onLostPointerCapture: end, onKeyDown: key }));
71
+ }
package/dist/PeekPanel.js CHANGED
@@ -9,6 +9,9 @@ import { formatNumber } from '@adecore/ui/format';
9
9
  import { basenameOf, relativeTo } from './paths.js';
10
10
  import { shikiLanguageOfPath } from './language-ids.js';
11
11
  import { PathText } from './PathText.js';
12
+ import { PanelResizeHandle } from './PanelResizeHandle.js';
13
+ import { usePanelBody } from './use-panel-body.js';
14
+ import { PANEL_BODY_HEIGHT } from './panel-size.js';
12
15
  import { PEEK_HEIGHT } from './peek.js';
13
16
  import { distinguishingFolders, visualColumnOf } from './peek-model.js';
14
17
  import { useEditorRendering } from './rendering-context.js';
@@ -93,11 +96,12 @@ export function PeekPanel({ language, view }) {
93
96
  const path = active === undefined ? null : fileUriToPath(active.location.uri);
94
97
  const folder = language.project.folder;
95
98
  const shown = path === null ? '' : folderOf(relativeTo(path, folder));
99
+ const body = usePanelBody(language);
96
100
  if (view.container === null) {
97
101
  return null;
98
102
  }
99
- return createPortal(_jsxs("div", { className: "flex flex-col bg-surface pt-[2px] font-sans text-xs text-text", style: { height: PEEK_HEIGHT }, onPointerDown: (event) => event.preventDefault(), children: [_jsx("div", { className: "peek-hatch h-[14px]" }), _jsxs("div", { className: "flex h-[31px] shrink-0 items-center gap-2 border-b border-border pr-2 pl-3 text-sm", children: [_jsx("span", { className: "shrink-0 font-medium", children: path === null ? '' : basenameOf(path) }), _jsx(PathText, { path: shown, className: "text-text-muted" }), _jsxs("div", { className: "ml-auto flex shrink-0 items-center gap-1", children: [_jsx("span", { className: "text-text-muted", children: t(view.kind === 'definitions' ? 'language.peek.definitions' : 'language.peek.references', {
103
+ return createPortal(_jsxs("div", { className: "flex flex-col bg-surface pt-[2px] font-sans text-xs text-text", style: { height: PEEK_HEIGHT - PANEL_BODY_HEIGHT + body.height }, onPointerDown: (event) => event.preventDefault(), children: [_jsx("div", { className: "peek-hatch h-[14px]" }), _jsxs("div", { className: "flex h-[31px] shrink-0 items-center gap-2 border-b border-border pr-2 pl-3 text-sm", children: [_jsx("span", { className: "shrink-0 font-medium", children: path === null ? '' : basenameOf(path) }), _jsx(PathText, { path: shown, className: "text-text-muted" }), _jsxs("div", { className: "ml-auto flex shrink-0 items-center gap-1", children: [_jsx("span", { className: "text-text-muted", children: t(view.kind === 'definitions' ? 'language.peek.definitions' : 'language.peek.references', {
100
104
  count: view.count,
101
105
  formatted: formatNumber(view.count)
102
- }) }), _jsx(IconButton, { icon: X, size: "xs", label: t('language.peek.close'), onClick: () => language.peek.close() })] })] }), _jsxs("div", { className: "flex h-[180px] shrink-0", children: [_jsx(Preview, { preview: view.preview }), _jsx(Places, { language: language, view: view })] }), _jsx("div", { className: "peek-hatch h-[14px]" })] }), view.container);
106
+ }) }), _jsx(IconButton, { icon: X, size: "xs", label: t('language.peek.close'), onClick: () => language.peek.close() })] })] }), _jsxs("div", { className: "flex shrink-0", style: { height: body.height }, children: [_jsx(Preview, { preview: view.preview }), _jsx(Places, { language: language, view: view })] }), _jsx("div", { className: "peek-hatch relative h-[14px]", children: _jsx(PanelResizeHandle, { container: view.container, height: body.height, onHeight: body.set, onCommit: body.commit }) })] }), view.container);
103
107
  }
@@ -24,19 +24,13 @@ export function rootsOf(items) {
24
24
  shown: 0
25
25
  }));
26
26
  }
27
- function compareItems(left, right) {
28
- return left.name.localeCompare(right.name) || left.uri.localeCompare(right.uri) || left.range.start.line - right.range.start.line;
29
- }
30
27
  /*
31
- * The children of a node made from the calls a server returned, listed by name the way the symbols of a
32
- * file are. The call sites of a caller are in its own file; those of a callee are in the file of the node
33
- * that was asked about.
28
+ * The children of a node made from the calls a server returned, in the order the server gave them.
29
+ * The call sites of a caller are in its own file; those of a callee are in the file of the node that was asked about.
34
30
  */
35
31
  export function childrenOf(direction, parent, calls) {
36
32
  const entries = calls.map((call) => ({ item: 'from' in call ? call.from : call.to, sites: call.fromRanges }));
37
- return entries
38
- .sort((left, right) => compareItems(left.item, right.item))
39
- .map((entry, index) => ({
33
+ return entries.map((entry, index) => ({
40
34
  id: `${parent.id}.${index}`,
41
35
  parent: parent.id,
42
36
  level: parent.level + 1,
@@ -4,7 +4,7 @@ import { PEEK_HEIGHT } from './peek.js';
4
4
  import { snippetOf } from './peek-model.js';
5
5
  import { isShortcut } from './shortcut-keys.js';
6
6
  import { messageOf } from './error-message.js';
7
- import { realTimers } from './timers.js';
7
+ import { realTimers, revealRow } from './timers.js';
8
8
  const TOAST_ID = 'language-call-hierarchy';
9
9
  const PREPARE = 'textDocument/prepareCallHierarchy';
10
10
  /* The row the panel stands in is as tall as a peek's, plus the segmented control that is a pixel taller than the peek's title bar. */
@@ -226,8 +226,7 @@ export class CallHierarchyFeature {
226
226
  const view = this.language.popups.getState().callHierarchy ?? null;
227
227
  if (view !== null && view.container !== container) {
228
228
  this.language.popups.setState({ callHierarchy: { ...view, container } });
229
- // The row opens under the caret line, which may sit at the bottom of the view with the tree below the fold. The element is in the page a turn later.
230
- this.timers.set(() => container.scrollIntoView?.({ block: 'nearest' }), 0);
229
+ revealRow(this.timers, container);
231
230
  }
232
231
  }
233
232
  patch(id, change) {
@@ -85,7 +85,7 @@ export class EditorLanguage {
85
85
  this.codeActions = new CodeActionsFeature(this, timers);
86
86
  this.rename = new RenameFeature(this);
87
87
  this.navigation = new NavigationFeature(this);
88
- this.peek = new PeekFeature(this);
88
+ this.peek = new PeekFeature(this, timers);
89
89
  this.callHierarchy = new CallHierarchyFeature(this, timers);
90
90
  this.symbolPicker = new SymbolPickerFeature(this);
91
91
  this.contextMenu = new ContextMenuFeature(this);
@@ -53,6 +53,8 @@ export interface LanguageHost {
53
53
  readonly files?: ProjectFiles;
54
54
  readonly apple?: boolean;
55
55
  readonly keymap?: Keymap;
56
+ readonly panelBodyHeight?: number;
57
+ onPanelResize?(height: number): void;
56
58
  openPlace?(place: Place): void;
57
59
  pathOfUri?(uri: string): string | null;
58
60
  serverNames?(uri: string): readonly string[];
@@ -82,6 +82,9 @@
82
82
  "symbol": "Matches"
83
83
  }
84
84
  },
85
+ "panel": {
86
+ "resize": "Resize panel"
87
+ },
85
88
  "peek": {
86
89
  "unavailable": "No language server finds references in this file",
87
90
  "none": "No references found",
@@ -82,6 +82,9 @@
82
82
  "symbol": "Overeenkomsten"
83
83
  }
84
84
  },
85
+ "panel": {
86
+ "resize": "Paneel vergroten of verkleinen"
87
+ },
85
88
  "peek": {
86
89
  "unavailable": "Geen language server vindt referenties in dit bestand",
87
90
  "none": "Geen referenties gevonden",
@@ -0,0 +1,6 @@
1
+ export declare const PANEL_BODY_HEIGHT = 180;
2
+ export declare const PANEL_BODY_MIN = 88;
3
+ export declare const PANEL_KEY_STEP = 22;
4
+ export declare function panelBodyMax(available: number): number;
5
+ export declare function clampPanelBody(height: number, available?: number): number;
6
+ export declare function availableHeightOf(container: HTMLElement | null): number;
@@ -0,0 +1,25 @@
1
+ /* The height of the list-and-preview area of a peek or a call hierarchy, which is what a person drags; the bands and the title bar around it stay. */
2
+ export const PANEL_BODY_HEIGHT = 180;
3
+ /* Four rows of 22px, so a list never shrinks to nothing. */
4
+ export const PANEL_BODY_MIN = 88;
5
+ /* What the bands and the title bar take of the panel, which the share of the editor has to leave room for. */
6
+ const PANEL_CHROME = 63;
7
+ /* The share of the editor's height a panel may take, so the line it belongs to stays in view. */
8
+ const PANEL_MAX_SHARE = 0.8;
9
+ const PANEL_FALLBACK_AVAILABLE = 800;
10
+ export const PANEL_KEY_STEP = 22;
11
+ /* The tallest body for an editor that is `available` pixels high; never below the minimum, so a tiny editor still gets a usable panel. */
12
+ export function panelBodyMax(available) {
13
+ return Math.max(PANEL_BODY_MIN, Math.floor(available * PANEL_MAX_SHARE) - PANEL_CHROME);
14
+ }
15
+ export function clampPanelBody(height, available = PANEL_FALLBACK_AVAILABLE) {
16
+ if (!Number.isFinite(height)) {
17
+ return PANEL_BODY_HEIGHT;
18
+ }
19
+ return Math.min(panelBodyMax(available), Math.max(PANEL_BODY_MIN, Math.round(height)));
20
+ }
21
+ /* The height of the editor's scrolling area around a panel's row; the fallback stands in where nothing is laid out. */
22
+ export function availableHeightOf(container) {
23
+ const height = container?.closest('.se-viewport')?.clientHeight ?? 0;
24
+ return height > 0 ? height : PANEL_FALLBACK_AVAILABLE;
25
+ }
package/dist/peek.d.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import type { EditorPosition } from '@adecore/editor';
2
2
  import type { EditorLanguage } from './editor-language.ts';
3
3
  import type { PeekView } from './popups.ts';
4
+ import { type Timers } from './timers.ts';
4
5
  export declare const PEEK_HEIGHT = 241;
5
6
  type PeekKind = PeekView['kind'];
6
7
  export declare class PeekFeature {
7
8
  private readonly language;
9
+ private readonly timers;
8
10
  private kind;
9
11
  private targets;
10
12
  private texts;
@@ -12,7 +14,7 @@ export declare class PeekFeature {
12
14
  private order;
13
15
  private line;
14
16
  private token;
15
- constructor(language: EditorLanguage);
17
+ constructor(language: EditorLanguage, timers?: Timers);
16
18
  get isOpen(): boolean;
17
19
  open(position?: EditorPosition, kind?: PeekKind): Promise<void>;
18
20
  openDefinition(position?: EditorPosition): Promise<void>;
package/dist/peek.js CHANGED
@@ -3,6 +3,7 @@ import { uniquePlaces } from './navigation.js';
3
3
  import { PEEK_READ_FILES, definitionSnippetOf, peekFilesOf, snippetOf } from './peek-model.js';
4
4
  import { isShortcut } from './shortcut-keys.js';
5
5
  import { messageOf } from './error-message.js';
6
+ import { realTimers, revealRow } from './timers.js';
6
7
  const TOAST_ID = 'language-peek';
7
8
  /* The block as the panel draws it: a hatched band, the header, eight lines of code and a hatched band, until the editor has measured it. */
8
9
  export const PEEK_HEIGHT = 241;
@@ -20,6 +21,7 @@ function placeKey(location) {
20
21
  */
21
22
  export class PeekFeature {
22
23
  language;
24
+ timers;
23
25
  kind = 'references';
24
26
  /* The whole declaration of each place of a definition peek, where the server gave it. */
25
27
  targets = new Map();
@@ -28,8 +30,9 @@ export class PeekFeature {
28
30
  order = [];
29
31
  line = 0;
30
32
  token = 0;
31
- constructor(language) {
33
+ constructor(language, timers = realTimers) {
32
34
  this.language = language;
35
+ this.timers = timers;
33
36
  const { editor } = language;
34
37
  const offs = [
35
38
  editor.onKeyDown((event) => {
@@ -179,6 +182,7 @@ export class PeekFeature {
179
182
  const view = this.language.popups.getState().peek;
180
183
  if (view !== null && view.container !== container) {
181
184
  this.language.popups.setState({ peek: { ...view, container } });
185
+ revealRow(this.timers, container);
182
186
  }
183
187
  }
184
188
  key(event) {
@@ -23,10 +23,14 @@ export declare class ProjectLanguage {
23
23
  readonly history: NavigationHistory;
24
24
  private readonly holders;
25
25
  private readonly files;
26
+ panelBodyHeight: number;
27
+ private readonly panelListeners;
26
28
  private pendingCaret;
27
29
  private lastPlace;
28
30
  private disposed;
29
31
  constructor(service: LanguageService, host?: LanguageHost);
32
+ onPanelBodyChange(listener: (height: number) => void): () => void;
33
+ resizePanelBody(height: number): void;
30
34
  pathOfLocation(uri: string): string | null;
31
35
  applyWorkspaceEdit(edit: WorkspaceEdit): Promise<ApplyWorkspaceEditResult>;
32
36
  willCreateFiles(targets: readonly FileOperationTarget[]): Promise<WorkspaceEdit | null>;
@@ -5,6 +5,7 @@ import { NavigationHistory } from './navigation-history.js';
5
5
  import { ProjectProblems } from './project-problems.js';
6
6
  import { applyWorkspaceEdit } from './workspace-edit.js';
7
7
  import { relativeTo } from './paths.js';
8
+ import { PANEL_BODY_HEIGHT } from './panel-size.js';
8
9
  /* Sends one editor's edits to the document, holding them until the document is open. */
9
10
  class DocumentSync {
10
11
  ready;
@@ -71,6 +72,9 @@ export class ProjectLanguage {
71
72
  history = new NavigationHistory();
72
73
  holders = new Map();
73
74
  files;
75
+ /* The body height of the peek and call hierarchy panels, shared by the editors of the project. */
76
+ panelBodyHeight;
77
+ panelListeners = new Set();
74
78
  pendingCaret = null;
75
79
  lastPlace = null;
76
80
  disposed = false;
@@ -80,8 +84,22 @@ export class ProjectLanguage {
80
84
  this.i18n = host.i18n ?? i18next;
81
85
  this.folder = host.folder ?? '';
82
86
  this.files = host.files ?? null;
87
+ this.panelBodyHeight = host.panelBodyHeight ?? PANEL_BODY_HEIGHT;
83
88
  this.problems = new ProjectProblems(service, (uri) => this.pathOfLocation(uri) ?? uri);
84
89
  }
90
+ /* Open panels of other editors of the project follow a resize. */
91
+ onPanelBodyChange(listener) {
92
+ this.panelListeners.add(listener);
93
+ return () => this.panelListeners.delete(listener);
94
+ }
95
+ resizePanelBody(height) {
96
+ if (height === this.panelBodyHeight) {
97
+ return;
98
+ }
99
+ this.panelBodyHeight = height;
100
+ this.panelListeners.forEach((listener) => listener(height));
101
+ this.host.onPanelResize?.(height);
102
+ }
85
103
  pathOfLocation(uri) {
86
104
  if (this.host.pathOfUri !== undefined) {
87
105
  return this.host.pathOfUri(uri);
@@ -4,6 +4,7 @@ import { type ParsedDocComment } from './doc-comment-model.ts';
4
4
  import { type Timers } from './timers.ts';
5
5
  export interface RenderedDocsSettings {
6
6
  readonly renderDocComments: boolean;
7
+ readonly enabled?: boolean;
7
8
  }
8
9
  export interface RenderedDocEntry {
9
10
  readonly id: string;
@@ -15,6 +16,7 @@ export declare class RenderedDocsFeature {
15
16
  private readonly language;
16
17
  private readonly timers;
17
18
  private settings;
19
+ private enabled;
18
20
  private comments;
19
21
  private overrides;
20
22
  private readonly containers;
@@ -44,6 +44,7 @@ export class RenderedDocsFeature {
44
44
  language;
45
45
  timers;
46
46
  settings = { renderDocComments: false };
47
+ enabled = true;
47
48
  comments = [];
48
49
  overrides = [];
49
50
  containers = new Map();
@@ -113,11 +114,16 @@ export class RenderedDocsFeature {
113
114
  }
114
115
  /* Whether every documentation comment is drawn as text until a person switches it. Changing it puts every comment back under it. */
115
116
  configure(settings) {
116
- if (settings.renderDocComments === this.settings.renderDocComments) {
117
+ const enabled = settings.enabled !== false;
118
+ if (settings.renderDocComments === this.settings.renderDocComments && enabled === this.enabled) {
117
119
  return;
118
120
  }
121
+ const sameRendering = settings.renderDocComments === this.settings.renderDocComments;
119
122
  this.settings = settings;
120
- this.clearOverrides();
123
+ this.enabled = enabled;
124
+ if (!sameRendering) {
125
+ this.clearOverrides();
126
+ }
121
127
  this.publish();
122
128
  }
123
129
  /* The documentation comments of the document, from the last time it settled. */
@@ -132,7 +138,7 @@ export class RenderedDocsFeature {
132
138
  toggle() {
133
139
  this.settle();
134
140
  const comment = this.at(this.language.editor.getCaret().line);
135
- if (comment === undefined || !this.canRender(comment)) {
141
+ if (!this.enabled || comment === undefined || !this.canRender(comment)) {
136
142
  return false;
137
143
  }
138
144
  this.setRendered(comment, !this.wantsRendered(comment));
@@ -142,7 +148,7 @@ export class RenderedDocsFeature {
142
148
  toggleAt(startLine) {
143
149
  this.settle();
144
150
  const comment = this.comments.find((candidate) => candidate.startLine === startLine);
145
- if (comment === undefined || !this.canRender(comment)) {
151
+ if (!this.enabled || comment === undefined || !this.canRender(comment)) {
146
152
  return false;
147
153
  }
148
154
  this.setRendered(comment, !this.wantsRendered(comment));
@@ -186,7 +192,7 @@ export class RenderedDocsFeature {
186
192
  return comment.startLine > 0 || comment.endLine < this.language.editor.positionAt(Number.MAX_SAFE_INTEGER).line;
187
193
  }
188
194
  wantsRendered(comment) {
189
- if (!this.canRender(comment)) {
195
+ if (!this.enabled || !this.canRender(comment)) {
190
196
  return false;
191
197
  }
192
198
  const start = startOf(comment);
@@ -325,7 +331,7 @@ export class RenderedDocsFeature {
325
331
  this.bump();
326
332
  }
327
333
  this.markerLines.clear();
328
- const markers = this.comments.map((comment) => {
334
+ const markers = (this.enabled ? this.comments : []).map((comment) => {
329
335
  const id = `docs:${comment.startLine}`;
330
336
  this.markerLines.set(id, comment.startLine);
331
337
  const on = this.wantsRendered(comment);
package/dist/timers.d.ts CHANGED
@@ -12,3 +12,4 @@ export declare class ManualTimers implements Timers {
12
12
  advance(milliseconds: number): void;
13
13
  get size(): number;
14
14
  }
15
+ export declare function revealRow(timers: Timers, container: HTMLElement): void;
package/dist/timers.js CHANGED
@@ -32,3 +32,7 @@ export class ManualTimers {
32
32
  return this.pending.size;
33
33
  }
34
34
  }
35
+ /* Scrolls a row the editor opened under the caret line into view, which may sit at the bottom of the view with the row below the fold. The element is in the page a turn later. */
36
+ export function revealRow(timers, container) {
37
+ timers.set(() => container.scrollIntoView?.({ block: 'nearest' }), 0);
38
+ }
@@ -0,0 +1,6 @@
1
+ import type { EditorLanguage } from './editor-language.ts';
2
+ export declare function usePanelBody(language: EditorLanguage): {
3
+ height: number;
4
+ set(height: number): void;
5
+ commit(height: number): void;
6
+ };
@@ -0,0 +1,13 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { clampPanelBody } from './panel-size.js';
3
+ /* The body height of a panel, from the project that remembers it for the next one; `commit` hands it to the host. */
4
+ export function usePanelBody(language) {
5
+ const { project } = language;
6
+ const [height, set] = useState(() => clampPanelBody(project.panelBodyHeight));
7
+ useEffect(() => project.onPanelBodyChange((value) => set(clampPanelBody(value))), [project]);
8
+ return {
9
+ height,
10
+ set,
11
+ commit: (value) => project.resizePanelBody(value)
12
+ };
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/editor-react",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
4
4
  "type": "module",
5
5
  "description": "Language-enabled editors, feature coordination and React popups over injected language services.",
6
6
  "license": "FSL-1.1-MIT",
@@ -62,9 +62,9 @@
62
62
  "test": "bun test --conditions=source"
63
63
  },
64
64
  "dependencies": {
65
- "@adecore/editor": "0.23.0",
66
- "@adecore/lsp": "0.23.0",
67
- "@adecore/merge": "0.23.0",
65
+ "@adecore/editor": "0.23.1",
66
+ "@adecore/lsp": "0.23.1",
67
+ "@adecore/merge": "0.23.1",
68
68
  "lucide-react": "^1.47.0",
69
69
  "react-markdown": "^10.1.0",
70
70
  "remark-breaks": "^4.0.0",
@@ -72,7 +72,7 @@
72
72
  "zustand": "^5.0.15"
73
73
  },
74
74
  "peerDependencies": {
75
- "@adecore/ui": "^0.23.0",
75
+ "@adecore/ui": "^0.23.1",
76
76
  "i18next": "^26.4.2",
77
77
  "react": "^19.3.0",
78
78
  "react-dom": "^19.3.0",
@@ -6,6 +6,9 @@ import { fileUriToPath } from '@adecore/lsp';
6
6
  import { Icon, IconButton, Segmented, Spinner, Tooltip } from '@adecore/ui';
7
7
  import { formatNumber } from '@adecore/ui/format';
8
8
  import { CALL_HIERARCHY_HEIGHT } from './call-hierarchy.ts';
9
+ import { PanelResizeHandle } from './PanelResizeHandle.tsx';
10
+ import { usePanelBody } from './use-panel-body.ts';
11
+ import { PANEL_BODY_HEIGHT } from './panel-size.ts';
9
12
  import { CALL_LEVEL_LIMIT, type CallDirection, type CallRow } from './call-hierarchy-model.ts';
10
13
  import type { EditorLanguage } from './editor-language.ts';
11
14
  import { basenameOf, relativeTo } from './paths.ts';
@@ -178,6 +181,7 @@ function StatusText({ row, direction }: { row: Extract<CallRow, { kind: 'status'
178
181
  */
179
182
  export function CallHierarchyPanel({ language, view }: { language: EditorLanguage; view: CallHierarchyView }) {
180
183
  const { t } = useTranslation('editor');
184
+ const body = usePanelBody(language);
181
185
  if (view.container === null) {
182
186
  return null;
183
187
  }
@@ -187,7 +191,7 @@ export function CallHierarchyPanel({ language, view }: { language: EditorLanguag
187
191
  return createPortal(
188
192
  <div
189
193
  className="flex flex-col bg-surface pt-[2px] font-sans text-xs text-text"
190
- style={{ height: CALL_HIERARCHY_HEIGHT }}
194
+ style={{ height: CALL_HIERARCHY_HEIGHT - PANEL_BODY_HEIGHT + body.height }}
191
195
  onPointerDown={(event) => event.preventDefault()}
192
196
  >
193
197
  <div className="peek-hatch h-[14px]" />
@@ -227,11 +231,13 @@ export function CallHierarchyPanel({ language, view }: { language: EditorLanguag
227
231
  <IconButton icon={X} size="xs" label={t('language.callHierarchy.close')} onClick={() => language.callHierarchy.close()} />
228
232
  </div>
229
233
  </div>
230
- <div className="flex h-[180px] shrink-0">
234
+ <div className="flex shrink-0" style={{ height: body.height }}>
231
235
  <Tree language={language} view={view} />
232
236
  <Preview preview={view.preview} />
233
237
  </div>
234
- <div className="peek-hatch h-[14px]" />
238
+ <div className="peek-hatch relative h-[14px]">
239
+ <PanelResizeHandle container={view.container} height={body.height} onHeight={body.set} onCommit={body.commit} />
240
+ </div>
235
241
  </div>,
236
242
  view.container
237
243
  );
@@ -0,0 +1,104 @@
1
+ import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { PANEL_BODY_MIN, PANEL_KEY_STEP, availableHeightOf, clampPanelBody, panelBodyMax } from './panel-size.ts';
4
+ import { realTimers, revealRow } from './timers.ts';
5
+
6
+ interface Props {
7
+ container: HTMLElement;
8
+ height: number;
9
+ onHeight(height: number): void;
10
+ onCommit(height: number): void;
11
+ }
12
+
13
+ /* The bottom edge of a panel: a drag, or the arrow keys, change the height of its body. */
14
+ export function PanelResizeHandle({ container, height, onHeight, onCommit }: Props) {
15
+ const { t } = useTranslation('editor');
16
+ const drag = useRef<{ startY: number; startHeight: number; lastY: number } | null>(null);
17
+ const [dragging, setDragging] = useState(false);
18
+ const [max, setMax] = useState(() => panelBodyMax(availableHeightOf(container)));
19
+
20
+ useEffect(() => {
21
+ const viewport = container.closest<HTMLElement>('.se-viewport');
22
+ const Observer = viewport?.ownerDocument.defaultView?.ResizeObserver;
23
+ if (viewport === null || viewport === undefined || Observer === undefined) {
24
+ return;
25
+ }
26
+ const observer = new Observer(() => setMax(panelBodyMax(availableHeightOf(container))));
27
+ observer.observe(viewport);
28
+ return () => observer.disconnect();
29
+ }, [container]);
30
+
31
+ function settle(value: number, from: number): void {
32
+ const next = clampPanelBody(value, availableHeightOf(container));
33
+ onHeight(next);
34
+ if (next !== from) {
35
+ onCommit(next);
36
+ revealRow(realTimers, container);
37
+ }
38
+ }
39
+
40
+ function heightAt(clientY: number): number {
41
+ return clampPanelBody(drag.current!.startHeight + clientY - drag.current!.startY, availableHeightOf(container));
42
+ }
43
+
44
+ function down(event: PointerEvent<HTMLDivElement>): void {
45
+ if (event.button !== 0) {
46
+ return;
47
+ }
48
+ event.currentTarget.setPointerCapture(event.pointerId);
49
+ drag.current = { startY: event.clientY, startHeight: height, lastY: event.clientY };
50
+ setDragging(true);
51
+ }
52
+
53
+ function move(event: PointerEvent<HTMLDivElement>): void {
54
+ if (drag.current !== null) {
55
+ drag.current.lastY = event.clientY;
56
+ onHeight(heightAt(event.clientY));
57
+ }
58
+ }
59
+
60
+ // Also the end of a drag whose capture was lost, such as a container that was replaced under the pointer.
61
+ function end(event: PointerEvent<HTMLDivElement>): void {
62
+ if (drag.current === null) {
63
+ return;
64
+ }
65
+ const { startHeight } = drag.current;
66
+ const next = heightAt(drag.current.lastY);
67
+ drag.current = null;
68
+ setDragging(false);
69
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
70
+ settle(next, startHeight);
71
+ }
72
+
73
+ function key(event: KeyboardEvent<HTMLDivElement>): void {
74
+ const step = event.shiftKey ? PANEL_KEY_STEP * 4 : PANEL_KEY_STEP;
75
+ const end = panelBodyMax(availableHeightOf(container));
76
+ const target = { ArrowUp: height - step, ArrowDown: height + step, Home: PANEL_BODY_MIN, End: end }[event.key];
77
+ if (target === undefined) {
78
+ return;
79
+ }
80
+ event.preventDefault();
81
+ event.stopPropagation();
82
+ settle(target, height);
83
+ }
84
+
85
+ return (
86
+ <div
87
+ role="separator"
88
+ aria-orientation="horizontal"
89
+ aria-label={t('language.panel.resize')}
90
+ aria-valuemin={PANEL_BODY_MIN}
91
+ aria-valuemax={max}
92
+ aria-valuenow={height}
93
+ tabIndex={0}
94
+ data-dragging={dragging}
95
+ className="absolute inset-x-0 bottom-0 z-10 h-1 cursor-row-resize touch-none outline-none hover:bg-accent-soft focus-visible:bg-accent-soft data-[dragging=true]:bg-accent-soft"
96
+ onPointerDown={down}
97
+ onPointerMove={move}
98
+ onPointerUp={end}
99
+ onPointerCancel={end}
100
+ onLostPointerCapture={end}
101
+ onKeyDown={key}
102
+ />
103
+ );
104
+ }
package/src/PeekPanel.tsx CHANGED
@@ -9,6 +9,9 @@ import { basenameOf, relativeTo } from './paths.ts';
9
9
  import type { EditorLanguage } from './editor-language.ts';
10
10
  import { shikiLanguageOfPath } from './language-ids.ts';
11
11
  import { PathText } from './PathText.tsx';
12
+ import { PanelResizeHandle } from './PanelResizeHandle.tsx';
13
+ import { usePanelBody } from './use-panel-body.ts';
14
+ import { PANEL_BODY_HEIGHT } from './panel-size.ts';
12
15
  import { PEEK_HEIGHT } from './peek.ts';
13
16
  import { distinguishingFolders, visualColumnOf, type NameRange, type PeekPlace } from './peek-model.ts';
14
17
  import type { PeekView } from './popups.ts';
@@ -179,13 +182,14 @@ export function PeekPanel({ language, view }: { language: EditorLanguage; view:
179
182
  const path = active === undefined ? null : fileUriToPath(active.location.uri);
180
183
  const folder = language.project.folder;
181
184
  const shown = path === null ? '' : folderOf(relativeTo(path, folder));
185
+ const body = usePanelBody(language);
182
186
  if (view.container === null) {
183
187
  return null;
184
188
  }
185
189
  return createPortal(
186
190
  <div
187
191
  className="flex flex-col bg-surface pt-[2px] font-sans text-xs text-text"
188
- style={{ height: PEEK_HEIGHT }}
192
+ style={{ height: PEEK_HEIGHT - PANEL_BODY_HEIGHT + body.height }}
189
193
  onPointerDown={(event) => event.preventDefault()}
190
194
  >
191
195
  <div className="peek-hatch h-[14px]" />
@@ -202,11 +206,13 @@ export function PeekPanel({ language, view }: { language: EditorLanguage; view:
202
206
  <IconButton icon={X} size="xs" label={t('language.peek.close')} onClick={() => language.peek.close()} />
203
207
  </div>
204
208
  </div>
205
- <div className="flex h-[180px] shrink-0">
209
+ <div className="flex shrink-0" style={{ height: body.height }}>
206
210
  <Preview preview={view.preview} />
207
211
  <Places language={language} view={view} />
208
212
  </div>
209
- <div className="peek-hatch h-[14px]" />
213
+ <div className="peek-hatch relative h-[14px]">
214
+ <PanelResizeHandle container={view.container} height={body.height} onHeight={body.set} onCommit={body.commit} />
215
+ </div>
210
216
  </div>,
211
217
  view.container
212
218
  );
@@ -81,32 +81,25 @@ export function rootsOf(items: readonly CallHierarchyItem[]): CallNode[] {
81
81
  }));
82
82
  }
83
83
 
84
- function compareItems(left: CallHierarchyItem, right: CallHierarchyItem): number {
85
- return left.name.localeCompare(right.name) || left.uri.localeCompare(right.uri) || left.range.start.line - right.range.start.line;
86
- }
87
-
88
84
  /*
89
- * The children of a node made from the calls a server returned, listed by name the way the symbols of a
90
- * file are. The call sites of a caller are in its own file; those of a callee are in the file of the node
91
- * that was asked about.
85
+ * The children of a node made from the calls a server returned, in the order the server gave them.
86
+ * The call sites of a caller are in its own file; those of a callee are in the file of the node that was asked about.
92
87
  */
93
88
  export function childrenOf(direction: CallDirection, parent: CallNode, calls: readonly (CallHierarchyIncomingCall | CallHierarchyOutgoingCall)[]): CallNode[] {
94
89
  const entries = calls.map((call) => ({ item: 'from' in call ? call.from : call.to, sites: call.fromRanges }));
95
- return entries
96
- .sort((left, right) => compareItems(left.item, right.item))
97
- .map((entry, index) => ({
98
- id: `${parent.id}.${index}`,
99
- parent: parent.id,
100
- level: parent.level + 1,
101
- item: entry.item,
102
- sites: entry.sites,
103
- siteUri: direction === 'incoming' ? entry.item.uri : parent.item.uri,
104
- expanded: false,
105
- load: 'idle',
106
- error: null,
107
- children: [],
108
- shown: 0
109
- }));
90
+ return entries.map((entry, index) => ({
91
+ id: `${parent.id}.${index}`,
92
+ parent: parent.id,
93
+ level: parent.level + 1,
94
+ item: entry.item,
95
+ sites: entry.sites,
96
+ siteUri: direction === 'incoming' ? entry.item.uri : parent.item.uri,
97
+ expanded: false,
98
+ load: 'idle',
99
+ error: null,
100
+ children: [],
101
+ shown: 0
102
+ }));
110
103
  }
111
104
 
112
105
  /* The lines the tree draws: every root, and under an expanded node its status, its calls and the row that shows more. */
@@ -18,7 +18,7 @@ import { PEEK_HEIGHT } from './peek.ts';
18
18
  import { snippetOf } from './peek-model.ts';
19
19
  import { isShortcut } from './shortcut-keys.ts';
20
20
  import { messageOf } from './error-message.ts';
21
- import { realTimers, type Timers } from './timers.ts';
21
+ import { realTimers, revealRow, type Timers } from './timers.ts';
22
22
 
23
23
  const TOAST_ID = 'language-call-hierarchy';
24
24
  const PREPARE = 'textDocument/prepareCallHierarchy';
@@ -262,8 +262,7 @@ export class CallHierarchyFeature {
262
262
  const view = this.language.popups.getState().callHierarchy ?? null;
263
263
  if (view !== null && view.container !== container) {
264
264
  this.language.popups.setState({ callHierarchy: { ...view, container } });
265
- // The row opens under the caret line, which may sit at the bottom of the view with the tree below the fold. The element is in the page a turn later.
266
- this.timers.set(() => container.scrollIntoView?.({ block: 'nearest' }), 0);
265
+ revealRow(this.timers, container);
267
266
  }
268
267
  }
269
268
 
@@ -97,7 +97,7 @@ export class EditorLanguage {
97
97
  this.codeActions = new CodeActionsFeature(this, timers);
98
98
  this.rename = new RenameFeature(this);
99
99
  this.navigation = new NavigationFeature(this);
100
- this.peek = new PeekFeature(this);
100
+ this.peek = new PeekFeature(this, timers);
101
101
  this.callHierarchy = new CallHierarchyFeature(this, timers);
102
102
  this.symbolPicker = new SymbolPickerFeature(this);
103
103
  this.contextMenu = new ContextMenuFeature(this);
package/src/host-types.ts CHANGED
@@ -61,6 +61,10 @@ export interface LanguageHost {
61
61
  readonly files?: ProjectFiles;
62
62
  readonly apple?: boolean;
63
63
  readonly keymap?: Keymap;
64
+ /* The height in pixels of the list-and-preview area of the peek and the call hierarchy, so a person's drag outlives the panel; 180 without one. */
65
+ readonly panelBodyHeight?: number;
66
+ /* A person finished resizing a peek or call hierarchy panel; the height is already within what the editor allows. */
67
+ onPanelResize?(height: number): void;
64
68
  openPlace?(place: Place): void;
65
69
  pathOfUri?(uri: string): string | null;
66
70
  serverNames?(uri: string): readonly string[];
@@ -82,6 +82,9 @@
82
82
  "symbol": "Matches"
83
83
  }
84
84
  },
85
+ "panel": {
86
+ "resize": "Resize panel"
87
+ },
85
88
  "peek": {
86
89
  "unavailable": "No language server finds references in this file",
87
90
  "none": "No references found",
@@ -82,6 +82,9 @@
82
82
  "symbol": "Overeenkomsten"
83
83
  }
84
84
  },
85
+ "panel": {
86
+ "resize": "Paneel vergroten of verkleinen"
87
+ },
85
88
  "peek": {
86
89
  "unavailable": "Geen language server vindt referenties in dit bestand",
87
90
  "none": "Geen referenties gevonden",
@@ -0,0 +1,28 @@
1
+ /* The height of the list-and-preview area of a peek or a call hierarchy, which is what a person drags; the bands and the title bar around it stay. */
2
+ export const PANEL_BODY_HEIGHT = 180;
3
+ /* Four rows of 22px, so a list never shrinks to nothing. */
4
+ export const PANEL_BODY_MIN = 88;
5
+ /* What the bands and the title bar take of the panel, which the share of the editor has to leave room for. */
6
+ const PANEL_CHROME = 63;
7
+ /* The share of the editor's height a panel may take, so the line it belongs to stays in view. */
8
+ const PANEL_MAX_SHARE = 0.8;
9
+ const PANEL_FALLBACK_AVAILABLE = 800;
10
+ export const PANEL_KEY_STEP = 22;
11
+
12
+ /* The tallest body for an editor that is `available` pixels high; never below the minimum, so a tiny editor still gets a usable panel. */
13
+ export function panelBodyMax(available: number): number {
14
+ return Math.max(PANEL_BODY_MIN, Math.floor(available * PANEL_MAX_SHARE) - PANEL_CHROME);
15
+ }
16
+
17
+ export function clampPanelBody(height: number, available: number = PANEL_FALLBACK_AVAILABLE): number {
18
+ if (!Number.isFinite(height)) {
19
+ return PANEL_BODY_HEIGHT;
20
+ }
21
+ return Math.min(panelBodyMax(available), Math.max(PANEL_BODY_MIN, Math.round(height)));
22
+ }
23
+
24
+ /* The height of the editor's scrolling area around a panel's row; the fallback stands in where nothing is laid out. */
25
+ export function availableHeightOf(container: HTMLElement | null): number {
26
+ const height = container?.closest<HTMLElement>('.se-viewport')?.clientHeight ?? 0;
27
+ return height > 0 ? height : PANEL_FALLBACK_AVAILABLE;
28
+ }
package/src/peek.ts CHANGED
@@ -6,6 +6,7 @@ import { PEEK_READ_FILES, definitionSnippetOf, peekFilesOf, snippetOf, type Peek
6
6
  import type { PeekView } from './popups.ts';
7
7
  import { isShortcut } from './shortcut-keys.ts';
8
8
  import { messageOf } from './error-message.ts';
9
+ import { realTimers, revealRow, type Timers } from './timers.ts';
9
10
 
10
11
  const TOAST_ID = 'language-peek';
11
12
  /* The block as the panel draws it: a hatched band, the header, eight lines of code and a hatched band, until the editor has measured it. */
@@ -29,6 +30,7 @@ function placeKey(location: Location): string {
29
30
  */
30
31
  export class PeekFeature {
31
32
  private readonly language: EditorLanguage;
33
+ private readonly timers: Timers;
32
34
  private kind: PeekKind = 'references';
33
35
  /* The whole declaration of each place of a definition peek, where the server gave it. */
34
36
  private targets = new Map<string, Range>();
@@ -38,8 +40,9 @@ export class PeekFeature {
38
40
  private line = 0;
39
41
  private token = 0;
40
42
 
41
- constructor(language: EditorLanguage) {
43
+ constructor(language: EditorLanguage, timers: Timers = realTimers) {
42
44
  this.language = language;
45
+ this.timers = timers;
43
46
  const { editor } = language;
44
47
  const offs = [
45
48
  editor.onKeyDown((event) => {
@@ -201,6 +204,7 @@ export class PeekFeature {
201
204
  const view = this.language.popups.getState().peek;
202
205
  if (view !== null && view.container !== container) {
203
206
  this.language.popups.setState({ peek: { ...view, container } });
207
+ revealRow(this.timers, container);
204
208
  }
205
209
  }
206
210
 
@@ -7,6 +7,7 @@ import { ProjectProblems } from './project-problems.ts';
7
7
  import { applyWorkspaceEdit, type ProjectFiles } from './workspace-edit.ts';
8
8
  import type { LanguageHost } from './host-types.ts';
9
9
  import { relativeTo } from './paths.ts';
10
+ import { PANEL_BODY_HEIGHT } from './panel-size.ts';
10
11
 
11
12
  export interface LanguageDocumentHandle {
12
13
  readonly uri: string;
@@ -98,6 +99,9 @@ export class ProjectLanguage {
98
99
  readonly history = new NavigationHistory();
99
100
  private readonly holders = new Map<string, Holder>();
100
101
  private readonly files: ProjectFiles | null;
102
+ /* The body height of the peek and call hierarchy panels, shared by the editors of the project. */
103
+ panelBodyHeight: number;
104
+ private readonly panelListeners = new Set<(height: number) => void>();
101
105
  private pendingCaret: Place | null = null;
102
106
  private lastPlace: Place | null = null;
103
107
  private disposed = false;
@@ -108,9 +112,25 @@ export class ProjectLanguage {
108
112
  this.i18n = host.i18n ?? i18next;
109
113
  this.folder = host.folder ?? '';
110
114
  this.files = host.files ?? null;
115
+ this.panelBodyHeight = host.panelBodyHeight ?? PANEL_BODY_HEIGHT;
111
116
  this.problems = new ProjectProblems(service, (uri) => this.pathOfLocation(uri) ?? uri);
112
117
  }
113
118
 
119
+ /* Open panels of other editors of the project follow a resize. */
120
+ onPanelBodyChange(listener: (height: number) => void): () => void {
121
+ this.panelListeners.add(listener);
122
+ return () => this.panelListeners.delete(listener);
123
+ }
124
+
125
+ resizePanelBody(height: number): void {
126
+ if (height === this.panelBodyHeight) {
127
+ return;
128
+ }
129
+ this.panelBodyHeight = height;
130
+ this.panelListeners.forEach((listener) => listener(height));
131
+ this.host.onPanelResize?.(height);
132
+ }
133
+
114
134
  pathOfLocation(uri: string): string | null {
115
135
  if (this.host.pathOfUri !== undefined) {
116
136
  return this.host.pathOfUri(uri);
@@ -16,6 +16,8 @@ const MAX_PARSED = 200;
16
16
  export interface RenderedDocsSettings {
17
17
  /* Draw every documentation comment as formatted text, as the Reader mode of the documentation does. Off by default. */
18
18
  readonly renderDocComments: boolean;
19
+ /* Off draws no buttons, ignores the shortcut and shows every comment as source; on unless false. */
20
+ readonly enabled?: boolean;
19
21
  }
20
22
 
21
23
  /* A documentation comment the editor drew a row for, and the element of that row. */
@@ -69,6 +71,7 @@ export class RenderedDocsFeature {
69
71
  private readonly language: EditorLanguage;
70
72
  private readonly timers: Timers;
71
73
  private settings: RenderedDocsSettings = { renderDocComments: false };
74
+ private enabled = true;
72
75
  private comments: DocComment[] = [];
73
76
  private overrides: Override[] = [];
74
77
  private readonly containers = new Map<string, HTMLElement>();
@@ -140,11 +143,16 @@ export class RenderedDocsFeature {
140
143
 
141
144
  /* Whether every documentation comment is drawn as text until a person switches it. Changing it puts every comment back under it. */
142
145
  configure(settings: RenderedDocsSettings): void {
143
- if (settings.renderDocComments === this.settings.renderDocComments) {
146
+ const enabled = settings.enabled !== false;
147
+ if (settings.renderDocComments === this.settings.renderDocComments && enabled === this.enabled) {
144
148
  return;
145
149
  }
150
+ const sameRendering = settings.renderDocComments === this.settings.renderDocComments;
146
151
  this.settings = settings;
147
- this.clearOverrides();
152
+ this.enabled = enabled;
153
+ if (!sameRendering) {
154
+ this.clearOverrides();
155
+ }
148
156
  this.publish();
149
157
  }
150
158
 
@@ -162,7 +170,7 @@ export class RenderedDocsFeature {
162
170
  toggle(): boolean {
163
171
  this.settle();
164
172
  const comment = this.at(this.language.editor.getCaret().line);
165
- if (comment === undefined || !this.canRender(comment)) {
173
+ if (!this.enabled || comment === undefined || !this.canRender(comment)) {
166
174
  return false;
167
175
  }
168
176
  this.setRendered(comment, !this.wantsRendered(comment));
@@ -173,7 +181,7 @@ export class RenderedDocsFeature {
173
181
  toggleAt(startLine: number): boolean {
174
182
  this.settle();
175
183
  const comment = this.comments.find((candidate) => candidate.startLine === startLine);
176
- if (comment === undefined || !this.canRender(comment)) {
184
+ if (!this.enabled || comment === undefined || !this.canRender(comment)) {
177
185
  return false;
178
186
  }
179
187
  this.setRendered(comment, !this.wantsRendered(comment));
@@ -225,7 +233,7 @@ export class RenderedDocsFeature {
225
233
  }
226
234
 
227
235
  private wantsRendered(comment: DocComment): boolean {
228
- if (!this.canRender(comment)) {
236
+ if (!this.enabled || !this.canRender(comment)) {
229
237
  return false;
230
238
  }
231
239
  const start = startOf(comment);
@@ -375,7 +383,7 @@ export class RenderedDocsFeature {
375
383
  this.bump();
376
384
  }
377
385
  this.markerLines.clear();
378
- const markers = this.comments.map((comment) => {
386
+ const markers = (this.enabled ? this.comments : []).map((comment) => {
379
387
  const id = `docs:${comment.startLine}`;
380
388
  this.markerLines.set(id, comment.startLine);
381
389
  const on = this.wantsRendered(comment);
package/src/timers.ts CHANGED
@@ -43,3 +43,8 @@ export class ManualTimers implements Timers {
43
43
  return this.pending.size;
44
44
  }
45
45
  }
46
+
47
+ /* Scrolls a row the editor opened under the caret line into view, which may sit at the bottom of the view with the row below the fold. The element is in the page a turn later. */
48
+ export function revealRow(timers: Timers, container: HTMLElement): void {
49
+ timers.set(() => container.scrollIntoView?.({ block: 'nearest' }), 0);
50
+ }
@@ -0,0 +1,17 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { EditorLanguage } from './editor-language.ts';
3
+ import { clampPanelBody } from './panel-size.ts';
4
+
5
+ /* The body height of a panel, from the project that remembers it for the next one; `commit` hands it to the host. */
6
+ export function usePanelBody(language: EditorLanguage): { height: number; set(height: number): void; commit(height: number): void } {
7
+ const { project } = language;
8
+ const [height, set] = useState(() => clampPanelBody(project.panelBodyHeight));
9
+
10
+ useEffect(() => project.onPanelBodyChange((value) => set(clampPanelBody(value))), [project]);
11
+
12
+ return {
13
+ height,
14
+ set,
15
+ commit: (value) => project.resizePanelBody(value)
16
+ };
17
+ }