Ping or open an object's asset from its hierarchy row (#353)

The asset link in the inspector already pings an asset on a click and
opens it on a double-click. The rows of the Scene objects panel only
selected and folded. Now they lead to the asset the same way.

- A click on the row that is selected already pings the object's
  asset in the asset panel. The selection stays. The ping comes on
  release, so picking a row up to drag it pings nothing.
- A double-click or Enter on an object's row opens its asset,
  selected or not.
- ListView has a new onReselect for a plain click on the row that is
  selected alone. onSelect stays quiet then, as nothing changes.

Decided without asking:
- A double-click on an object's row no longer folds it. The chevron
  and the arrow keys still do. A group's row has no asset and keeps
  folding on a double-click.
- A click with Ctrl or Shift, and one that narrows a multi-selection
  to its row, does not ping. Both change the selection.
- A row whose asset is missing pings and opens nothing.

Findings:
- The double-click window goes by time (400 ms), not by the event's
  detail. Two quick click() calls from the harness are a double-click.
  The comment in e2e 01 said otherwise and is gone.

Tests:
- e2e 01 (melektaus-atx): ALL PASSED, 13 checks. The two checks for
  folding on a double-click became one for the ping and two for
  opening. Counter-check without the ping: that check red.
- Gate (melektaus-atx): CHECK EXIT 0, 268 files, 4142 tests.
- Not checked: Enter on a row, a group's row on a double-click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-07 23:25:05 +02:00
1 parent 1d53b9b3d0
commit c95fbc167e
3 files changed
+74 -12

No files matched your search

+23 -1
View File
@@ -14,6 +14,7 @@ import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { groupName, objectName } from '../../core/model/SceneObject';
import { deviationMarks } from '../../core/properties/deviation';
import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui';
import { activateAsset } from '../actions';
import { groupRowItems, selectGroup } from '../objectGroups';
import { openObjectMenu } from '../objectMenu';
import { ASSET_ICON_SVG, GROUP_ICON_SVG } from './assetIcons';
@@ -59,7 +60,11 @@ export class HierarchyPanel extends Panel {
};
this.list.onDrop = (draggedId, spot) => this.drop(draggedId, spot);
this.list.onToggle = (id, expand) => this.setExpanded(id, expand);
this.list.onActivate = (id) => this.setExpanded(id, !this.expanded.has(id));
this.list.onActivate = (id) => this.activate(id);
this.list.onReselect = (id) => {
const assetId = this.assetOfRow(id);
if (assetId) state.pingAsset(assetId);
};
this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y);
this.scroll.setContent(this.list);
this.content.add(this.scroll, 1);
@@ -98,6 +103,23 @@ export class HierarchyPanel extends Panel {
this.state.execute(oneStep(many ? 'Move objects' : 'Move object', commands));
}
/**
* A double-click or Enter (#353): an object's row opens its asset, as a double-click on the asset
* link in the inspector does. A group's row has no asset and folds instead.
*/
private activate(rowId: string): void {
const assetId = this.assetOfRow(rowId);
if (assetId) activateAsset(this.state, assetId);
else if (groupIdOfRow(rowId)) this.setExpanded(rowId, !this.expanded.has(rowId));
}
/** The asset an object's row was placed from – null for a group's row or a missing asset. */
private assetOfRow(rowId: string): string | null {
const holder = this.holder();
const object = holder && !groupIdOfRow(rowId) ? findObject(holder, rowId) : undefined;
return object && findAsset(this.state.project, object.assetId) ? object.assetId : null;
}
/** Ctrl-click on a group row: all members join the selection, or leave it when all were in. */
private toggleGroup(holderId: string, groupId: string): void {
const holder = this.holder();
+11 -1
View File
@@ -93,6 +93,8 @@ interface DragState {
spot: DropSpot | null;
/** Pressed inside a multi-selection: a release that never travelled narrows it to this row. */
narrows: boolean;
/** Pressed on the row that was selected alone already: a release that never travelled reselects it. */
reselects: boolean;
}
/**
@@ -109,6 +111,11 @@ export class ListView extends Widget {
private contextId: string | null = null;
onSelect?: (id: string | null, modifiers?: PickModifiers) => void;
onActivate?: (id: string) => void;
/**
* A plain click on the row that is selected already – `onSelect` stays quiet then, as nothing
* changes. In a list whose rows can be dragged it comes on release, so picking a row up is not it.
*/
onReselect?: (id: string) => void;
/** Returning the opened menu keeps its row marked for as long as it is up. */
onContextMenu?: (id: string, globalX: number, globalY: number) => Popup | void;
/** A right-click on the empty space below the rows or on a heading, which have no menu of their own. */
@@ -349,6 +356,7 @@ export class ListView extends Widget {
// A press inside a multi-selection keeps it, so the selection is still there while dragging;
// only a plain click (see `onDragEnd`) narrows it down to this row.
const narrows = this.highlighted.size > 1 && this.highlighted.has(item.id);
const reselects = !narrows && item.id === this.selectedId;
const dragged = Boolean(this.onDrop || this.onDragOut);
if (narrows && !dragged) this.pick(item.id);
else if (!narrows && !this.onDragOut && (!this.highlighted.has(item.id) || item.id === this.selectedId))
@@ -362,8 +370,9 @@ export class ListView extends Widget {
active: false,
spot: null,
narrows,
reselects,
};
}
} else if (reselects) this.onReselect?.(item.id);
}
}
@@ -411,6 +420,7 @@ export class ListView extends Widget {
if (!drag?.active) {
// The press never travelled, so it was a click after all – now it may select.
if (drag?.narrows) this.pick(drag.id);
else if (drag?.reselects) this.onReselect?.(drag.id);
else if (drag && this.onDragOut) this.select(drag.id);
return;
}