dl.f3liz.casa/drop を読んでいます
webpanel
ウェブページを、タブの横に置く(Floorp のパネルサイドバーの芯だけ。縦列のアイコン、今のタブを足す、幅を覚える)
uuid 71e1b258-533d-4424-abdb-7e77c5d5fdd9
install
Press inside noraneko. It catches the link, opens its settings with this uuid, downloads the xpi, checks the sha256 and the registry's signature, and shows you everything below again before anything runs.
about:nora:settings in noraneko instead.
who
gh/nyanruswhat is served
webpanel · webpanel@noraneko.app · version 2.5.0 · 51203 bytes sha256 61328a0034bb396a13a94baa50ac1aeba5489abb1d2ea7eb17de2b27e91ae064 built from drops/webpanel/src @ 59b96c854c (2026-09-14T07:24:00Z) served at https://dl.f3liz.casa/drop/71e1b258-533d-4424-abdb-7e77c5d5fdd9/in the registry
drops/webpanel/ @ 59b96c854c — この版が出てきた木 actors: webpanelthe source, as it is
These come from source.json next to the manifest — the same text the xpi carries under source/, and dl checks its sha256 against the signed manifest before handing it over. Not a copy of the repository: what you read and what runs came out of the same build. 5 files; press one to open it.
_shared/defineActor.ts · 118 lines
// SPDX-License-Identifier: MPL-2.0
// Authoring surface for an "actor": a small xpi whose page side is a
// JSWindowActor (the same shape as Firefox's own about:newtab add-on). Each
// actor lives in one file that exports three things by name:
//
// export const meta = { id, namespace, matches, ... };
// export const parent = defineParent({ method(...) { ... } }); // main process (JSWindowActorParent)
// export const content = defineContent((parent, ctx) => { ... }); // page side (run by JSWindowActorChild)
//
// The page side is NOT a WebExtension content script: content scripts are not
// injected into about:* / chrome:* documents in stock Firefox (see README,
// "addon 式が駄目だった理由"). The child actor loads content.js into the page's
// process with `window` / `document` on the scope chain, so the hook is written
// the same way, but it runs with the child actor's privileges.
//
// Named exports (rather than one nested object) are deliberate: the build
// bundles `parent` and `content` into separate outputs, and named exports let
// the bundler tree-shake each side so the content bundle never drags in the
// parent's privileged code and vice versa.
//
// IMPORTANT: the module top level must be pure. Reference Firefox globals
// (Services, ChromeUtils, window, exportFunction, browser) only *inside* method
// or hook bodies — the build imports this file under Deno to read `meta`, and
// top-level use of those globals would throw there.
export interface ActorMeta {
/** Extension id, e.g. "settings-bridge@noraneko.app". */
id: string;
/** Extension version. */
version: string;
/** Experiment-API namespace, e.g. "noraSettings". Also the message channel. */
namespace: string;
/** Match patterns for the pages the content hook runs in (JSWindowActor `matches`). */
matches: string[];
/**
* When the content hook runs. Maps to a child actor event:
* document_start → DOMDocElementInserted, document_end → DOMContentLoaded,
* document_idle → load. Defaults to "document_end".
*/
runAt?: "document_start" | "document_end" | "document_idle";
/**
* JSWindowActor name. Defaults to "Nora" + PascalCase(<dir>), e.g. NoraNewtab.
* A drop with the same actor name replaces the built-in one (unregister → register).
*/
actor?: string;
/** Legacy JSActor (BrowserGlue) this one replaces; it is unregistered when this actor is registered. */
replaces?: string;
}
import type { Io } from "./io.ts";
type ParentMethods = Record<string, (...args: any[]) => unknown>;
export interface ContentCtx {
/** True in dev builds (import.meta.env.MODE === "dev"). */
dev: boolean;
/** Expose functions on the page window (via exportFunction). */
expose(funcs: Record<string, (...args: any[]) => unknown>): void;
/**
* Run when this actor goes away in this window (the drop is removed or
* replaced). Put back what the hook changed: DOM, observers, listeners.
*/
onDestroy(fn: () => void): void;
/**
* Place things through these (DOM, styles, listeners, pref observers) and
* they are taken out again by themselves: each verb puts its way back on
* the same ledger as onDestroy.
*/
io: Io;
/** This actor's own files: `${ctx.base}ops/x.tsubaki` and the like. */
base: string;
/**
* The actor's logic in Tsubaki (when it ships wasm/): run a .tsubaki file of
* its own with load("ops/x.tsubaki"), then call(name, ...args). Values cross
* as ordinary JS values; a Tsubaki error is a JS Error. undefined without wasm/.
*
* Every verb answers with a Promise, because for an actor of the browser
* WINDOW the logic lives a process away (wasm can't compile in the parent
* process, so it runs in a hidden page of this window instead). A drop never
* has to know which side it got.
*/
ops: Ops | undefined;
}
export interface Ops {
ready: Promise<void>;
call(name: string, ...args: unknown[]): Promise<unknown>;
}
/** Proxy to the parent methods; each call is forwarded to the main process. */
type ParentProxy<T extends ParentMethods> = {
[K in keyof T]: (...args: Parameters<T[K]>) => Promise<Awaited<ReturnType<T[K]>>>;
};
export type ContentHook<T extends ParentMethods = ParentMethods> = (
parent: ParentProxy<T>,
ctx: ContentCtx,
) => void;
// Identity helpers that pin types for authoring:
//
// export const parent = defineParent({ method(...) { ... } });
// export const content = defineContent<typeof parent>((parent, ctx) => { ... });
//
// They are listed in the build's treeshake.manualPureFunctions, so an unused
// call (e.g. defineContent in the parent bundle) is dropped along with anything
// it references (birpc). Without that, the bundler would keep the call for its
// possible side effects and the unused side would not tree-shake.
/** Pins the parent-method types so the content hook's proxy is typed. */
export const defineParent = <T extends ParentMethods>(methods: T): T => methods;
/** Pins the content hook against the parent's method types. */
export const defineContent = <T extends ParentMethods>(
hook: ContentHook<T>,
): ContentHook<T> => hook;
webpanel/actor.ts · 30 lines
// SPDX-License-Identifier: MPL-2.0
// GENERATED by build.ts. この drop の actor は Tsubaki で書かれている:
// 直すのは ops/Style.tsubaki / ops/webpanel.tsubaki と drop.toml の [actor]。
//
// 殻は lib の std-actor。logic が答える三つの door(setup / start / dispatch)
// を呼んで、返ってきた view を描き、effects を carry out する。殻の bytes は
// その lib に一枚だけあって、ここには入らない(この drop に残るのは、下の宣言だけ)。
import { defineContent, defineParent, type ActorMeta } from "../_shared/defineActor.ts";
import { runTsubakiActor } from "std-actor";
export const meta: ActorMeta = {
"id": "webpanel@noraneko.app",
"version": "2.5.0",
"namespace": "noraWebpanel",
"matches": [
"chrome://browser/content/browser.xhtml",
"chrome://noraneko-settings/*"
],
"runAt": "document_end"
};
export const parent = defineParent({});
export const content = defineContent<typeof parent>((_parent, ctx) => {
runTsubakiActor(ctx, {"prefs":["floorp.panelSidebar.data","floorp.panelSidebar.config"],"keys":[],"commands":[],"browserPages":["bookmarks","history","downloads","library"],"menus":[],"tabs":"","tabMarks":false,"tabValues":false,"windowValues":false,"prompt":false,"currentUrl":true,"openUrl":false,"webFrame":true,"chromeStyle":true,"ownPrefix":"noraneko.webpanel.","name":"webpanel","uuid":"71e1b258-533d-4424-abdb-7e77c5d5fdd9","strings":{"en":{"add":"Add the current tab","reload":"Reload","close":"Close","unload":"Unload","remove":"Remove","side_start":"Put the strip on the left of the tabs","default_width":"Default width","pages_hint":"The browser's own pages, as panels:","page_bookmarks":"Bookmarks","page_history":"History","page_downloads":"Downloads","page_library":"Library"},"ja":{"add":"いまのタブを足す","reload":"読み込み直す","close":"閉じる","unload":"いったん外す","remove":"消す","side_start":"帯を、タブの左に置く","default_width":"既定の幅","pages_hint":"ブラウザ自身のページを、パネルに:","page_bookmarks":"ブックマーク","page_history":"履歴","page_downloads":"ダウンロード","page_library":"ライブラリ"}}}).catch((e) =>
console.error("[webpanel] failed:", e)
);
});
webpanel/ops/Style.tsubaki · 81 lines
# SPDX-License-Identifier: MPL-2.0
#
# webpanel's stylesheet. A wall of CSS is not logic, so it sits in a file of its
# own -- and `import Style` over in webpanel.tsubaki is what puts this file
# first. The build follows that line (tooling/webext-actors/build.ts, opsFiles):
# a drop's logic runs in a worker with no files to read, so the reading is done
# there, and by the time the drop runs the module is simply already here.
#
# (Tsubaki has no multi-line string, so the rules are joined; one line here is
# one rule.)
module Style
sheet() = (
"#nora-webpanel-start, #nora-webpanel-end { align-items: stretch; }" *
"#nora-webpanel-start:empty, #nora-webpanel-end:empty { display: none; }" *
# Firefox lays #browser out with CSS order (sidebar 1-4, tabs 5, ai window 6-7).
# "start" shares the tabs' 5 and wins on document order; "end" goes after them all.
"#nora-webpanel-start { order: 5; }" *
"#nora-webpanel-end { order: 8; }" *
"#nora-webpanel-box { min-width: 200px;" *
" background-color: var(--toolbar-bgcolor, var(--toolbar-background-color)); }" *
"#nora-webpanel-header { align-items: center; height: 32px; padding: 0 4px 0 10px; gap: 4px; }" *
"#nora-webpanel-title { flex: 1; overflow: hidden; text-overflow: ellipsis;" *
" white-space: nowrap; font-weight: 600; }" *
"#nora-webpanel-header toolbarbutton { appearance: none; padding: 4px; border-radius: 4px;" *
" -moz-context-properties: fill; fill: currentColor; }" *
"#nora-webpanel-header toolbarbutton:hover {" *
" background-color: color-mix(in srgb, currentColor 12%, transparent); }" *
"#nora-webpanel-browsers browser { flex: 1; }" *
"#nora-webpanel-browsers browser:not([selected=\"true\"]) { visibility: collapse; }" *
"#nora-webpanel-splitter { appearance: none; width: 4px; min-width: 4px;" *
" background-color: transparent; border: none; }" *
"#nora-webpanel-splitter:hover {" *
" background-color: color-mix(in srgb, currentColor 20%, transparent); }"
)
# --- 帯と、その中のボタン ------------------------------------------------
# ここは字ではなくデータ。CSS にするのは殻(_shared/style.ts)で、drop は
# 一度も CSS を綴らない。`40px` は juxtaposition、色は std の Theme。
#
# `[selected="true"]` は要らなくなった -- 選ばれているかどうかは logic が
# 知っているので、擬似クラスに訊かずに、そのとき置く色を渡す。
strip() = Dict(
:width => 40px, :min_width => 40px,
:padding => (4px, 0),
:align_items => :center,
:gap => 2px,
:background_color => Theme.bg,
:border_inline => (1px, :solid, Theme.separator),
)
icon_button(selected) = Dict(
:appearance => :none,
:width => 32px, :height => 32px,
:padding => 6px,
:border_radius => 4px,
:_moz_context_properties => :fill,
:fill => :currentColor,
:background_color => selected ? Theme.active : nothing,
":hover" => Dict(:background_color => Theme.hover),
" .toolbarbutton-icon" => Dict(:width => 20px, :height => 20px),
)
# --- 設定の一枚 ----------------------------------------------------------
# about:nora:settings の中なので、ここだけは HTML の document に居る。
# 頁の色は借りる(自分で決めない)-- 紙のほうが変わっても、ここは一緒に動く。
pane() = Dict(:display => :flex, :flex_direction => :column, :gap => 10px)
hint() = Dict(:margin => (8px, 0, 0), :color => Theme.muted)
row() = Dict(
:display => :flex,
:align_items => :center,
:gap => 8px,
)
number() = Dict(:width => 6em)
end
webpanel/ops/webpanel.tsubaki · 621 lines
# SPDX-License-Identifier: MPL-2.0
#
# webpanel: web pages in a sidebar next to the tabs. Floorp's panel sidebar,
# reduced to its core: a strip of icons, one <browser> per panel, "add the
# current tab", and the width remembered. No floating mode, no mobile UA, no
# containers, no settings page. Those can come as separate drops later.
#
# This is the whole drop. There is no actor.ts, no .tsx, no io/ -- the build
# puts the same shell around this as around every other drop whose actor is
# written in Tsubaki (tooling/webext-actors/_shared/tsubakiActor.ts), and what
# a reviewer reads is this file plus a shell they read once.
#
# setup() where the view goes, its stylesheet, which prefs to follow
# start(facts) the first frame (facts = what was already true)
# dispatch(a) the next frame, and what to do about it
#
# A frame is `frame(view)` / `frame(view, effects)`. The view is data -- no DOM,
# no preact -- and an effect is data too, carried out by the shell. The shell's
# whole vocabulary is seven words; this drop uses six of them.
#
# Nothing here makes a fact. A fresh uuid, the URL of the tab you are on, the
# width a box ended up after a drag, the title of a page that finished loading:
# each one arrives inside an action, asked for by name.
#
# The panel list is kept in the same shape as Floorp's floorp.panelSidebar.data
# ({ data: Panel[] }), so a list from Floorp carries over: read once when we have
# nothing of our own, then only ours is written. The two settings come over the
# same way, but each into a pref of its own (cf. f3liz-casa/noraneko#127).
PREF_DATA = "noraneko.webpanel.data"
PREF_WIDTH = "noraneko.webpanel.globalWidth"
PREF_START = "noraneko.webpanel.positionStart"
FLOORP_DATA = "floorp.panelSidebar.data"
FLOORP_CONFIG = "floorp.panelSidebar.config"
# ブラウザ自身のページを出すパネル。Floorp の静的パネルと**同じ綴り**を url に持つので、
# 引き取ったリストの中のそれが、そのまま出る -- 引き継いだ日にアイコンが四つ減る、
# がここで閉じる。名前(bookmarks / history / ...)は殻の表(abi/v1.json の browser_pages)
# のもので、drop は chrome:// の綴りを一度も書かない。
pages() = [
Dict("floorp" => "floorp//bookmarks", "page" => "bookmarks",
"icon" => "chrome://browser/skin/bookmark.svg", "label" => t(:page_bookmarks)),
Dict("floorp" => "floorp//history", "page" => "history",
"icon" => "chrome://browser/skin/history.svg", "label" => t(:page_history)),
Dict("floorp" => "floorp//downloads", "page" => "downloads",
"icon" => "chrome://browser/skin/downloads/downloads.svg", "label" => t(:page_downloads)),
Dict("floorp" => "floorp//bmt", "page" => "library",
"icon" => "chrome://browser/skin/library.svg", "label" => t(:page_library))
]
"その url の静的パネルが、どのページか。知らない綴りなら nothing"
function page_entry(url)
for e in pages()
if get(e, :floorp, "") == url
return e
end
end
return nothing
end
"名前から表の一行"
function entry_for(name)
for e in pages()
if get(e, :page, "") == name
return e
end
end
return nothing
end
MENU = "#nora-webpanel-menu"
BOX = "#nora-webpanel-box"
# `import Style` is what tells the build to read Style.tsubaki before this file
# (see its header). Nothing is glued together: both files stay their own.
import Style
# --- setup --------------------------------------------------------------------
# Two places are prepared for the strip, one on each side of the tabs, and the
# view puts it in whichever one the setting names; the other draws nothing and
# is not there to see (`:empty` in the stylesheet). Which side it is on is a
# pref, and a pref is not known yet when this is answered -- so it is the view
# that chooses, not the anchor.
#
# Firefox lays #browser out with CSS order (sidebar 1-4, tabs 5, ai window 6-7).
# Sharing the tabs' own 5 puts us next to them, and among equals the document
# order decides -- which is why "start" is placed BEFORE the tabbox and "end"
# after it, and only "end" needs an order of its own.
#
# Changing the side while a window is open now takes effect at once, where it
# used to wait for the next window. The panels that were open reload, because
# they cross from one root to the other and that is a fresh element on the other
# side -- moving inside one root does not (see docs/TRAPS.md).
setup() = Dict(
"anchors" => [
Dict("name" => "start", "at" => "before", "selector" => "#tabbrowser-tabbox",
"tag" => "hbox", "id" => "nora-webpanel-start"),
Dict("name" => "end", "at" => "after", "selector" => "#tabbrowser-tabbox",
"tag" => "hbox", "id" => "nora-webpanel-end"),
Dict("name" => "menu", "at" => "parent", "selector" => "#mainPopupSet",
"tag" => "menupopup", "id" => "nora-webpanel-menu"),
# about:nora:settings の、この drop の一枚。窓には出ないし、窓の三つは
# あちらには出ない -- 置き場所のほうに行き先が書いてあって、view は
# どちらでも同じ名前で答える。二つの document は memory を分け合わない
# けれど、pref は分け合うので、片方で変えるともう片方がその場で追う。
Dict("name" => "settings", "at" => "settings")
],
"style" => Style.sheet(),
"prefs" => [PREF_WIDTH, PREF_START],
"prefs_json" => [PREF_DATA, FLOORP_DATA, FLOORP_CONFIG]
)
# --- the data ----------------------------------------------------------------
# `raw` is the entry exactly as the prefs hold it. Floorp's list carries fields
# this drop doesn't read (icon, userContextId, zoomLevel, userAgent,
# extensionId); keeping the whole thing means writing the list back never
# quietly drops them.
struct Panel
id
kind
width
url
raw
end
@kwdef struct AppState
panels = [] # Panel, in the order the prefs hold them
selected = "" # "" = the box is closed
loaded = [] # ids whose <browser> is drawn, oldest first
titles = Dict() # id -> the page title, as last told
width = 400
position_start = false # the strip on the left of the tabs
menu_target = "" # which panel the context menu was opened on
end
# ("type" は文字のまま: `type` は Tsubaki のキーワードなので `:type` が書けない)
panel_of_dict(d) = Panel(get(d, :id, ""), get(d, "type", "static"), get(d, :width, 0), get(d, :url, ""), d)
panel_to_dict(p) = put(p.raw, :width, p.width)
as_dicts(panels) = [panel_to_dict(p) for p in panels]
"the ones the strip shows: web panels that have a URL, and pages we know"
visible(p) = p.kind == "web" ? p.url != "" : page_entry(p.url) !== nothing
shown(s) = filter(visible, s.panels)
"その名前のページのパネル。まだ無ければ nothing"
function page_panel(s, name)
for p in s.panels
e = page_entry(p.url)
if e !== nothing && get(e, :page, "") == name
return p
end
end
return nothing
end
function find_panel(s, id)
for p in s.panels
if p.id == id
return p
end
end
return nothing
end
function title_of(s)
if s.selected == ""
return ""
end
told = get(s.titles, s.selected, "")
if told != ""
return told
end
p = find_panel(s, s.selected)
if p === nothing
return ""
end
e = page_entry(p.url)
return e === nothing ? p.url : get(e, :label, "")
end
function width_of(s)
p = find_panel(s, s.selected)
return p === nothing || p.width == 0 ? s.width : p.width
end
frame_id(id) = "nora-webpanel-" * id
# --- actions: what happened, as data -----------------------------------------
abstract type Action end
struct Select <: Action # "" closes the box; the open panel's id closes it too
id
end
struct AddClicked <: Action end # the + button. What to add is asked for, not known here
struct AddPanel <: Action # the answer: a fresh uuid and the URL of the tab you are on
uuid
url
end
struct Reload <: Action
id
end
struct Unload <: Action # take the <browser> away, keep the panel
id
end
struct Remove <: Action # take the panel out of the list as well
id
end
struct SetTitle <: Action # a page said what it is called
id
title
end
struct DragEnded <: Action end # the splitter was let go; the width is asked for
struct SetWidth <: Action # ...and this is the answer
width
end
struct PrefChanged <: Action # the prefs changed (here, or in another window)
name
value
end
struct OpenMenu <: Action # x/y come from the click, in __event
id
x
y
end
# --- 設定の一枚から来るもの ---------------------------------------------------
# どちらも「その pref を書く」だけ。窓のほうは pref を見ているので、書けば
# そのまま追ってくる -- 設定の頁が窓に話しかける道は、要らなかった。
struct SetSide <: Action # 帯を、タブの左に置くかどうか
start
end
struct SetGlobalWidth <: Action # 何も覚えていないパネルの、既定の幅
width
end
struct TogglePage <: Action # ブラウザ自身のページのパネルを、足す / 外す
page
end
# --- effects ------------------------------------------------------------------
# All of them are the shell's own words (std): SetPref / Ask / Measure /
# OpenPopup / ReloadFrame. This drop needs no effect of its own -- showing and
# hiding a panel is not something to do, it is something the view says.
# --- update: (state, action) -> (state, effects) ------------------------------
struct Step
state
effects
end
persist(panels) = SetPref(PREF_DATA, Dict("data" => as_dicts(panels)))
function update(s::AppState, a::Select)
if a.id == "" || a.id == s.selected
return Step(AppState(s, selected = "", menu_target = ""), [])
end
p = find_panel(s, a.id)
if p === nothing
return Step(s, [])
end
loaded = a.id in s.loaded ? s.loaded : vcat(s.loaded, [a.id])
return Step(AppState(s, selected = a.id, loaded = loaded, menu_target = ""), [])
end
update(s::AppState, a::AddClicked) = Step(s, [Ask(["uuid", "url"], "AddPanel")])
function update(s::AppState, a::AddPanel)
if a.url == "" # not a web page: nothing to put in a panel
return Step(s, [])
end
fresh = Dict("id" => a.uuid, "type" => "web", "width" => 0, "url" => a.url)
ns = AppState(s, panels = vcat(s.panels, [panel_of_dict(fresh)]))
st = update(ns, Select(a.uuid))
return Step(st.state, vcat(st.effects, [persist(st.state.panels)]))
end
update(s::AppState, a::Reload) = Step(s, [ReloadFrame("#" * frame_id(a.id))])
function update(s::AppState, a::Unload)
closing = s.selected == a.id
ns = AppState(s,
loaded = filter(id -> id != a.id, s.loaded),
selected = closing ? "" : s.selected,
menu_target = "")
return Step(ns, [])
end
function update(s::AppState, a::Remove)
st = update(s, Unload(a.id))
panels = filter(p -> p.id != a.id, st.state.panels)
return Step(AppState(st.state, panels = panels), vcat(st.effects, [persist(panels)]))
end
update(s::AppState, a::SetTitle) = Step(AppState(s, titles = put(s.titles, a.id, a.title)), [])
update(s::AppState, a::DragEnded) = Step(s, [Measure(BOX, "SetWidth")])
function update(s::AppState, a::SetWidth)
if s.selected == "" || a.width <= 0
return Step(s, [])
end
panels = [p.id == s.selected ? Panel(p.id, p.kind, a.width, p.url, p.raw) : p for p in s.panels]
return Step(AppState(s, width = a.width, panels = panels), [persist(panels)])
end
function update(s::AppState, a::PrefChanged)
if a.name == PREF_DATA
return sync_panels(s, panels_of(a.value))
elseif a.name == PREF_WIDTH
return Step(AppState(s, width = a.value === nothing ? s.width : a.value), [])
elseif a.name == PREF_START
return Step(AppState(s, position_start = a.value === nothing ? false : a.value), [])
end
return Step(s, [])
end
function sync_panels(s, panels)
ids = [p.id for p in filter(p -> p.kind == "web" && p.url != "", panels)]
return Step(AppState(s,
panels = panels,
loaded = filter(id -> id in ids, s.loaded),
selected = s.selected in ids ? s.selected : ""), [])
end
update(s::AppState, a::OpenMenu) =
Step(AppState(s, menu_target = a.id), [OpenPopup(MENU, a.x, a.y)])
update(s::AppState, a::SetSide) =
Step(AppState(s, position_start = a.start), [SetPref(PREF_START, a.start)])
# 幅は入力欄から字で来る。数にならない字(空、途中まで、貼り間違い)は **書かない**
# -- 直している途中の一文字ごとに pref が上書きされるより、指が止まるまで待つほうが。
# もう有れば外す、無ければ足す。id はページの名前から組む -- 同じページを二つ
# 並べても仕方がないので、uuid を訊く必要が無い。
function update(s::AppState, a::TogglePage)
found = page_panel(s, a.page)
if found !== nothing
return update(s, Remove(found.id))
end
e = entry_for(a.page)
if e === nothing
return Step(s, [])
end
fresh = Dict("id" => "nora-page-" * a.page, "type" => "static",
"width" => 0, "url" => get(e, :floorp, ""))
ns = AppState(s, panels = vcat(s.panels, [panel_of_dict(fresh)]))
return Step(ns, [persist(ns.panels)])
end
function update(s::AppState, a::SetGlobalWidth)
w = a.width
if w <= 0
return Step(s, [])
end
return Step(AppState(s, width = w), [SetPref(PREF_WIDTH, w)])
end
# --- view: state -> plain data ------------------------------------------------
# The value of an "on:*" prop is the ACTION to raise, not a closure. The shell
# hands it back to `dispatch` as it is, with what only that side can know
# (`__event`: where the click was on the screen, what a page is called).
#
# The strip goes in one of the two anchors and the other draws nothing. The
# <browser>s are ordinary elements here -- each one keyed by its panel's id, so
# a redraw moves it rather than making it again (the shell's mount moves without
# taking anything out; a <browser> that is taken out loses its page).
view(s) = Dict(
"start" => s.position_start ? sidebar(s) : nothing,
"end" => s.position_start ? nothing : sidebar(s),
"menu" => menu(s),
"settings" => pane(s)
)
function sidebar(s)
parts = s.position_start ? [strip(s), box(s), splitter(s)] : [splitter(s), box(s), strip(s)]
return el("fragment", Dict(), parts)
end
function strip_button(s, p)
e = page_entry(p.url)
return el("toolbarbutton", Dict(
"key" => p.id,
"style" => Style.icon_button(p.id == s.selected),
"image" => e === nothing ? "page-icon:" * p.url : get(e, :icon, ""),
"tooltiptext" => e === nothing ? p.url : get(e, :label, ""),
"selected" => p.id == s.selected ? "true" : nothing,
"on:command" => Select(p.id),
"on:contextmenu" => OpenMenu(p.id, 0, 0)
))
end
function strip(s)
add = el("toolbarbutton", Dict(
"style" => Style.icon_button(false),
"image" => "chrome://global/skin/icons/plus.svg",
"tooltiptext" => t(:add),
"on:command" => AddClicked()
))
return el("vbox", Dict("id" => "nora-webpanel-strip", "style" => Style.strip()),
vcat([strip_button(s, p) for p in shown(s)], [add]))
end
function frame_for(s, id)
p = find_panel(s, id)
base = Dict(
"key" => id,
"id" => frame_id(id),
"selected" => id == s.selected ? "true" : nothing,
"on:pagetitlechanged" => SetTitle(id, "")
)
if p === nothing
return el("browser", base)
end
e = page_entry(p.url)
return e === nothing ? el("browser", put(base, "src", p.url)) :
el("browser", put(base, "page", get(e, :page, "")))
end
function box(s)
open = s.selected != ""
header = el("hbox", Dict("id" => "nora-webpanel-header"), [
el("label", Dict("id" => "nora-webpanel-title", "crop" => "end", "value" => title_of(s))),
el("toolbarbutton", Dict(
"image" => "chrome://global/skin/icons/reload.svg",
"tooltiptext" => t(:reload),
"on:command" => Reload(s.selected)
)),
el("toolbarbutton", Dict(
"image" => "chrome://global/skin/icons/close.svg",
"tooltiptext" => t(:close),
"on:command" => Select("")
))
])
frames = el("vbox", Dict("id" => "nora-webpanel-browsers", "flex" => "1"),
[frame_for(s, id) for id in s.loaded])
return el("vbox", Dict(
"id" => "nora-webpanel-box",
"hidden" => !open,
"style" => open ? Dict("width" => string(width_of(s)) * "px") : Dict()
), [header, frames])
end
splitter(s) = el("splitter", Dict(
"id" => "nora-webpanel-splitter",
"hidden" => s.selected == "",
"on:command" => DragEnded()
))
menu_item(label, action) = el("menuitem", Dict("label" => label, "on:command" => action))
menu(s) = el("fragment", Dict(), [
menu_item(t(:reload), Reload(s.menu_target)),
menu_item(t(:unload), Unload(s.menu_target)),
menu_item(t(:remove), Remove(s.menu_target))
])
# 設定の一枚。about:nora:settings の中の、この drop のところに出る。
# ここは HTML の document なので div / input / span(窓のほうは XUL)。
# 前は about:config でしか変えられなかった二つが、名前のついた行になる。
pane(s) = el("div", Dict("style" => Style.pane()), vcat([
el("label", Dict("style" => Style.row()), [
el("input", Dict("type" => "checkbox", "checked" => s.position_start,
"on:change" => SetSide(false))),
el("span", Dict(), [t(:side_start)])
]),
el("label", Dict("style" => Style.row()), [
el("span", Dict(), [t(:default_width)]),
el("input", Dict("type" => "number", "style" => Style.number(),
"value" => string(s.width),
"on:change" => SetGlobalWidth(0)))
]),
el("p", Dict("style" => Style.hint()), [t(:pages_hint)])
], [page_row(s, e) for e in pages()]))
# ブラウザ自身のページのパネルは、ここで足したり外したりする。「いまのタブを足す」
# では作れないので(タブになっていない)、置き場所はここしか無い。
page_row(s, e) = el("label", Dict("style" => Style.row()), [
el("input", Dict("type" => "checkbox",
"checked" => page_panel(s, get(e, :page, "")) !== nothing,
"on:change" => TogglePage(get(e, :page, "")))),
el("span", Dict(), [get(e, :label, "")])
])
# --- the doors ----------------------------------------------------------------
# One state, kept here between calls (the worker holds this scope for the life of
# the window).
STATE = AppState()
"the { data: [...] } a panel-list pref spells, as Panels. Anything else: none."
function panels_of(held)
if held === nothing
return nothing
end
rows = get(held, :data, nothing)
if rows === nothing
return nothing
end
return [panel_of_dict(d) for d in rows]
end
# Ours if we have answered it; otherwise Floorp's, copied once -- from then on
# only ours is read and written. Floorp's pref is left as it is.
function first_panels(prefs)
ours = panels_of(get(prefs, PREF_DATA, nothing))
if ours !== nothing
return Dict("panels" => ours, "adopted" => false)
end
theirs = panels_of(get(prefs, FLOORP_DATA, nothing))
if theirs !== nothing
return Dict("panels" => theirs, "adopted" => true)
end
return Dict("panels" => [], "adopted" => false)
end
"a setting: ours if answered, else Floorp's out of its one JSON pref, else the default"
function first_setting(prefs, name, floorp_key, default)
held = get(prefs, name, nothing)
if held !== nothing
return Dict("value" => held, "adopted" => false)
end
config = get(prefs, FLOORP_CONFIG, nothing)
theirs = config === nothing ? nothing : get(config, floorp_key, nothing)
return Dict("value" => theirs === nothing ? default : theirs, "adopted" => true)
end
function start(facts)
prefs = get(facts, :prefs, Dict())
panels = first_panels(prefs)
width = first_setting(prefs, PREF_WIDTH, "globalWidth", 400)
start_side = first_setting(prefs, PREF_START, "position_start", false)
STATE = AppState(
panels = panels["panels"],
width = width["value"],
position_start = start_side["value"])
# whatever was taken over is written down here, once, so that from now on
# these prefs are the only ones read -- and so a setting can be found in
# about:config instead of only existing when someone changes it
effects = []
if panels["adopted"]
effects = vcat(effects, [persist(STATE.panels)])
end
if width["adopted"]
effects = vcat(effects, [SetPref(PREF_WIDTH, width["value"])])
end
if start_side["adopted"]
effects = vcat(effects, [SetPref(PREF_START, start_side["value"])])
end
return frame(view(STATE), effects)
end
function dispatch(a)
st = update(STATE, action_of(a))
STATE = st.state
return frame(view(STATE), st.effects)
end
"入力欄の字を数に。数にならないものは 0(呼ぶほうが、何もしないことを決める)"
function as_int(text)
try
return parse(Int, text)
catch
return 0
end
end
"a plain object from the shell back into the action it names"
function action_of(a)
kind = get(a, :__type, "")
ev = get(a, :__event, Dict())
if kind == "Select"
return Select(get(a, :id, ""))
elseif kind == "AddClicked"
return AddClicked()
elseif kind == "AddPanel"
return AddPanel(get(a, :uuid, ""), get(a, :url, ""))
elseif kind == "Reload"
return Reload(get(a, :id, ""))
elseif kind == "Unload"
return Unload(get(a, :id, ""))
elseif kind == "Remove"
return Remove(get(a, :id, ""))
elseif kind == "SetTitle"
return SetTitle(get(a, :id, ""), get(ev, :title, ""))
elseif kind == "DragEnded"
return DragEnded()
elseif kind == "SetWidth"
return SetWidth(get(a, :width, 0))
elseif kind == "PrefChanged"
return PrefChanged(get(a, :name, ""), get(a, :value, nothing))
elseif kind == "OpenMenu"
return OpenMenu(get(a, :id, ""), get(ev, :screenX, 0), get(ev, :screenY, 0))
elseif kind == "SetSide"
return SetSide(get(ev, :checked, false))
elseif kind == "SetGlobalWidth"
return SetGlobalWidth(as_int(get(ev, :value, "")))
elseif kind == "TogglePage"
return TogglePage(get(a, :page, ""))
end
error("webpanel: no action named " * kind)
end
webpanel/strings.toml · 32 lines
# 言葉。logic は t(:add) と鍵で書き、どの字になるかは殻が Services.locale で決める。
# 並べていない鍵は、鍵そのものが出る(黙って消えない)。
# en は最後の頼りなので、鍵は全部ここに有ること。
[en]
add = "Add the current tab"
reload = "Reload"
close = "Close"
unload = "Unload"
remove = "Remove"
side_start = "Put the strip on the left of the tabs"
default_width = "Default width"
pages_hint = "The browser's own pages, as panels:"
page_bookmarks = "Bookmarks"
page_history = "History"
page_downloads = "Downloads"
page_library = "Library"
[ja]
add = "いまのタブを足す"
reload = "読み込み直す"
close = "閉じる"
unload = "いったん外す"
remove = "消す"
side_start = "帯を、タブの左に置く"
default_width = "既定の幅"
pages_hint = "ブラウザ自身のページを、パネルに:"
page_bookmarks = "ブックマーク"
page_history = "履歴"
page_downloads = "ダウンロード"
page_library = "ライブラリ"
What a drop is, how it reaches you, and what it is not: f3liz.casa/noraneko/drops/. Served by https://dl.f3liz.casa/drop, signed by https://github.com/f3liz-casa/noraneko-registry/.github/workflows/verify-and-sign.yml@refs/heads/main.
This page is built when you ask for it, out of what https://dl.f3liz.casa/drop is serving right now (2026-09-23T08:38:28.032Z). Drafted by Shiro, an AI assistant, sitting next to nyanrus.