feat(editor): 文件树「在应用中打开」子菜单(外部编辑器/图钉固定/SSH 远端/自定义编辑器) (#334)

* feat(editor): file tree 'open with' submenu (external editors, pin, SSH-remote, custom editors)

The file tree right-click menu gains an 'Open with >' submenu with the
resource manager (reveal in file manager), VS Code, Cursor and Zed, plus
user-defined custom editors (name + URL template + VSCode-family flag).
Each submenu row carries a pushpin that promotes the target to a direct
top-level menu item.

A configurable SSH host (settings: Files card gear) switches VSCode-family
openers to <scheme>://vscode-remote/ssh-remote+<host>/<path> and hides
host-local targets (file manager / Zed / non-VSCode custom editors).

External opens run through the new fenced /sidebar/api/open.external route
which spawns the platform opener with an argv array (no shell); the client
never navigates raw scheme URLs itself. The settings seam's render panel now
appends after the declarative rows instead of replacing them.

* feat(editor): polish open-with menu UI — brand icons, pushpin, chevron, row alignment

- Submenu rows: the custom label is now a block-level flex row filling the
  primitives itemLabel, so the pushpin docks at the row's far right (matching
  the trailing check of every other menu); menu row icons normalized to 16px
  (DSH native menus use 16, ours were 14).
- The submenu parent row gains a right-aligned chevron (the primitives Menu
  renders no arrow for submenu parents).
- Icons via react-icons: codicons VscPin/VscPinned pushpins (replacing the
  hand-drawn pin), VscFile/VscFolder/VscFolderOpened tree glyphs (replacing
  the </> file mark), VscLinkExternal for the open-with parent and the
  browser tab's open-external button (globally), SiCursor/SiZedindustries
  brand silhouettes; the VS Code mark is inlined from simple-icons@11.0.0
  (CC0) as IconVscode16 since later simple-icons releases dropped it. All
  monochrome currentColor to follow the active skin.
- tsdown: pin react-icons si/vsc to their ESM entries via resolve.alias —
  the package's exports map lists require before import, so the shared
  conditionNames picked the unshakeable CJS entry and bloated the core
  bundle by ~6.4 MB; tree-shaken it now adds only ~12 KB.
This commit is contained in:
Atlas Zeng
2026-08-22 23:19:17 +08:00
committed by GitHub
parent f36afc0180
commit 22a5d372be
27 changed files with 1662 additions and 65 deletions
+45 -42
View File
@@ -492,12 +492,13 @@ function SelectRow(props: {
/**
* The secondary settings popup body of one feature (tab or viewer):
* - `settings.render` (custom panel) when declared — rendered with the
* shared store/service, the live prefs, the descriptor's own plugin
* settings blob, a persistence helper, and a close callback;
* - otherwise the host-prefs `toggles` rows, then the plugin-owned
* `pluginToggles` rows (their values live in `pluginSettings[feature.id]`,
* projected onto the prefs face so the shared row renderer reads them).
* - the host-prefs `toggles` rows, then the plugin-owned `pluginToggles`
* rows (their values live in `pluginSettings[feature.id]`, projected onto
* the prefs face so the shared row renderer reads them);
* - `settings.render` (custom panel) AFTER those rows when declared — the
* custom panel is an extension of the row list, not a replacement, so a
* feature can keep its declarative rows (e.g. the editor's
* open-behavior picker) and still ship a custom configuration area.
*/
export function SettingsBody(props: {
feature: TabDescriptor | FileViewerDescriptor
@@ -515,48 +516,50 @@ export function SettingsBody(props: {
}) {
const { feature, prefs, store, service, onToggle, onCommit, onSelectValue, onPluginToggle, onPluginCommit, onPluginSelectValue, onPluginWrite, onClose } = props
const render = feature.settings?.render
if (render !== undefined) {
return (
<SettingsRender
render={render}
renderProps={{
store,
service,
prefs,
pluginSettings: prefs.pluginSettings[feature.id] ?? {},
updatePluginSetting: onPluginWrite,
close: onClose,
}}
/>
)
}
const toggles = feature.settings?.toggles ?? []
const pluginToggles = feature.settings?.pluginToggles ?? []
if (toggles.length === 0 && pluginToggles.length === 0) return null
if (render === undefined && toggles.length === 0 && pluginToggles.length === 0) return null
// Plugin rows read their values from the descriptor's OWN blob through
// an explicit value source — no projection onto the prefs face, so a
// plugin key can never collide with (or silently read) a host pref of
// the same name.
const pluginBlob = prefs.pluginSettings[feature.id] ?? {}
return (
<div className={css.popupRows}>
{toggles.length > 0 && (
<FeatureSettingsRows
toggles={toggles}
prefs={prefs}
onToggle={onToggle}
onCommit={onCommit}
onSelectValue={onSelectValue}
/>
<div>
{(toggles.length > 0 || pluginToggles.length > 0) && (
<div className={css.popupRows}>
{toggles.length > 0 && (
<FeatureSettingsRows
toggles={toggles}
prefs={prefs}
onToggle={onToggle}
onCommit={onCommit}
onSelectValue={onSelectValue}
/>
)}
{pluginToggles.length > 0 && (
<FeatureSettingsRows
toggles={pluginToggles}
prefs={prefs}
onToggle={onPluginToggle}
onCommit={onPluginCommit}
onSelectValue={onPluginSelectValue}
valueSource={(key) => pluginBlob[key]}
/>
)}
</div>
)}
{pluginToggles.length > 0 && (
<FeatureSettingsRows
toggles={pluginToggles}
prefs={prefs}
onToggle={onPluginToggle}
onCommit={onPluginCommit}
onSelectValue={onPluginSelectValue}
valueSource={(key) => pluginBlob[key]}
{render !== undefined && (
<SettingsRender
render={render}
renderProps={{
store,
service,
prefs,
pluginSettings: prefs.pluginSettings[feature.id] ?? {},
updatePluginSetting: onPluginWrite,
close: onClose,
}}
/>
)}
</div>
@@ -1045,9 +1048,9 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
feature is open — the Modal primitive runs hooks unconditionally,
so a closed-but-mounted Modal would break SSR (and the
renderToString spec) under the test dual-react split.
Content: `settings.render` (custom panel) when declared, else the
host-prefs `toggles` rows followed by the plugin-owned
`pluginToggles` rows (their values live in pluginSettings[id]). */}
Content: the host-prefs `toggles` rows, the plugin-owned
`pluginToggles` rows (their values live in pluginSettings[id]),
then the custom `settings.render` panel when declared. */}
{settingsFor !== null && (
<Modal
open