noraneko · drops

dl.f3liz.casa/drop を読んでいます

workspaces

タブを束にして、切り替えて使う。束はタブについて回るので、閉じて開き直しても、別の窓に引っ張っても、その束のまま

uuid 229ecd53-123b-4d18-94b1-1b2ece7b347f

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.

install in noraneko In another browser this is a plain link to the xpi. Stock Firefox will refuse it as unverified; paste the uuid into about:nora:settings in noraneko instead.

who

gh/nyanrus

what is served

workspaces · workspaces@noraneko.app · version 1.2.0 · 44386 bytes sha256 b3af07615994b8219d7be401d2adf8c63ad081f05bf27ee16ff5e2afda3704b1 built from drops/workspaces/src @ 59b96c854c (2026-09-14T07:24:00Z) served at https://dl.f3liz.casa/drop/229ecd53-123b-4d18-94b1-1b2ece7b347f/

in the registry

drops/workspaces/ @ 59b96c854c — この版が出てきた木 actors: workspaces

the 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;
workspaces/actor.ts · 30 lines
// SPDX-License-Identifier: MPL-2.0
// GENERATED by build.ts. この drop の actor は Tsubaki で書かれている:
// 直すのは ops/Style.tsubaki / ops/workspaces.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": "workspaces@noraneko.app",
  "version": "1.2.0",
  "namespace": "noraWorkspaces",
  "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":[],"keys":[],"commands":["open-new-tab"],"browserPages":[],"menus":["tabContextMenu"],"tabs":"write","tabMarks":false,"tabValues":true,"windowValues":true,"prompt":false,"currentUrl":false,"openUrl":false,"webFrame":false,"chromeStyle":false,"ownPrefix":"noraneko.workspaces.","name":"workspaces","uuid":"229ecd53-123b-4d18-94b1-1b2ece7b347f","strings":{"en":{"add":"Add a workspace","remove":"Remove","hint":"Removing a workspace moves its tabs into the one you are in. No tab is closed.","switch":"Switch to this workspace","move_to":"Move tab to workspace"},"ja":{"add":"束を足す","remove":"消す","hint":"束を消すと、その中のタブは、いま開いている束に移ります。タブは一枚も閉じません。","switch":"この束に切り替える","move_to":"このタブを別の束へ"}}}).catch((e) =>
    console.error("[workspaces] failed:", e)
  );
});
workspaces/ops/Style.tsubaki · 29 lines
# SPDX-License-Identifier: MPL-2.0
#
# 見た目は、字ではなくデータ。CSS にするのは殻(_shared/style.ts)で、この drop は
# 生の CSS を一枚も持たない -- だから `chrome_style` の宣言も要らない
# (自分が置いたものの見た目しか変えないので)。

module Style
    # タブ帯の中の、束のボタンの並び
    bar() = Dict(:align_items => :center, :gap => 2px, :padding => (0, 4px))

    button(selected) = Dict(
        :appearance => :none,
        :padding => (2px, 8px),
        :border_radius => 4px,
        :max_width => 12em,
        :background_color => selected ? Theme.active : nothing,
        ":hover" => Dict(:background_color => Theme.hover),
    )

    # 設定の一枚。about:nora:settings の中なので、ここは HTML の document
    pane() = Dict(:display => :flex, :flex_direction => :column, :gap => 8px)

    row() = Dict(:display => :flex, :align_items => :center, :gap => 8px)

    field() = Dict(:flex => 1, :max_width => 16em)

    hint() = Dict(:margin => (8px, 0, 0), :color => Theme.muted)
end
workspaces/ops/workspaces.tsubaki · 530 lines
# SPDX-License-Identifier: MPL-2.0
#
# workspaces: タブを束にして、切り替えて使う。
#
# 束が、どこに住んでいるか:
#
#   束の一覧        pref noraneko.workspaces.list(自分の名前空間。JSON の並び)
#   いま開いている束  **窓の覚書** ── 窓ごとなので、窓を二つ開いて別の束を見られる
#   タブの束        **タブの覚書** ── タブについて回るので、再起動でも、別の窓に
#                   引っ張っても、そのタブはその束のまま
#   最後に見ていた   タブの覚書(その束の id)。戻ってきたときに、続きから
#
# Floorp はタブの属性を永続化するために gecko を 504 行パッチしていました。
# 覚書(SessionStore の per-tab value)はそのまま残るので、ここでは一行も要りません。
#
# 切り替えるときの順は、**先に選んで、あとで仕舞う**。選ばれているタブは本体が
# 仕舞わないので、逆にすると一枚だけ残ります。
#
# 前回から戻ってくるところは、setup() の `restore` に任せます。戻ってきてから
# 仕舞い直すと **一瞬ぜんぶ見える** ので、本体がまとめて挿す前に、殻が焼きます。
#
# 設定の頁にも、同じ logic が居ます。そちらには窓のタブが無いので、束を作ったり
# 仕舞ったりはしません(`at_window` が、その分かれ目)。

import Style

PREF_LIST = "noraneko.workspaces.list"
NEW_TAB = "open-new-tab"
KEY = "ws"        # タブの覚書: そのタブが、どの束のものか
LAST = "last"     # タブの覚書: その束で最後に見ていたタブ
WKEY = "ws"       # 窓の覚書: いま開いている束

# --- setup --------------------------------------------------------------------
# 置き場所は二つ。束のボタンはタブ帯(入れた人が customize mode で動かせる)、
# 束を足したり名前を変えたりするのは設定の一枚。
#
# 見ていてほしいのは四つ。開いた(束を付ける)/ 戻ってきた / 閉じた・選ばれた
# (どれがいまのタブか)。

setup() = Setup(
    anchors = [
        Anchor(name = "bar", at = "toolbar", area = "TabsToolbar", id = "nora-workspaces-bar"),
        Anchor(name = "settings", at = "settings", id = "nora-workspaces-settings"),
        Anchor(name = "menu", at = "menu", menu = "tabContextMenu", id = "nora-workspaces-menu")
    ],
    prefs_json = [PREF_LIST],
    watch = Watch(tabs = ["open", "restore", "close", "select"]),
    tab_values = [KEY, LAST],
    window_values = [WKEY],
    # 戻ってくるタブのうち、いま開いている束と違うものは、仕舞ったまま戻す
    restore = Restore(hide_unless = KEY)
)

# 覚えているのは、殻が最後に渡してきたタブの一覧と、束の一覧と、いまの束。
# どれも「いま本当のこと」で、二か所で覚えない
@kwdef struct AppState
    tabs = []
    list = []
    here = ""
    at_window = false
end

STATE = AppState()

# --- 読みかた -------------------------------------------------------------------

id_of(w) = get(w, :id, "")
name_of(w) = get(w, :name, "")
values_of(tab) = get(tab, :values, Dict())
ws_of(tab) = get(values_of(tab), KEY, "")
last_of(tab) = get(values_of(tab), LAST, "")
hidden_of(tab) = get(tab, :hidden, false) === true

"その束が、まだ一覧に居るか"
has_ws(list, ws) = length(filter(w -> id_of(w) == ws, list)) > 0

"いま選ばれているタブ(無ければ nothing)"
function selected_tab(tabs)
    for tab in tabs
        if get(tab, :selected, false) === true
            return tab
        end
    end
    return nothing
end

"その id のタブ(無ければ nothing)"
function find_tab(tabs, id)
    for tab in tabs
        if tab_id(tab) == id
            return tab
        end
    end
    return nothing
end

# 殻から来る一覧は「その瞬間の事実」だけれど、**自分が書いたばかりの覚書は、まだ
# 乗っていないことがある** ── 書くのと、一覧が作られるのは、同じ瞬間ではないので。
#
# 実機で一度これで転んだ: タブを開いて覚書を書き、その直後に「選ばれた」の一覧が
# 来ると、そのタブの覚書が空で届く。一覧で丸ごと上書きすると、自分が書いたことを
# 忘れて、そのタブは「どの束にも属さないタブ」になり、次に切り替えたときに
# **いま開いた束のほうへ引っ張られてしまう**。
#
# だから覚書だけは重ねる。来たほうに有ればそちら、無ければ手元のを信じる。
# 見えているかどうか・選ばれているかどうかは、来たほうがいつも本当。
merge_tab(was, now) = was === nothing ? now : put(now, "values", merge(values_of(was), values_of(now)))

remember(old, fresh) = [merge_tab(find_tab(old, tab_id(t)), t) for t in fresh]

"その束で最後に見ていたタブ(無ければ nothing)"
function last_tab(tabs, ws)
    for tab in tabs
        # 束そのものも見る。別の束へ移したタブに古い覚書が残っていても、
        # それはもうこの束の「続き」ではない(見ているタブを移したときに、
        # そのタブを選び直してしまっていた)
        if last_of(tab) == ws && ws_of(tab) == ws
            return tab
        end
    end
    return nothing
end

"pref の JSON を、束の一覧として読む。読めないものは、束が無いのと同じ"
function list_of(held)
    if held === nothing
        return []
    end
    return filter(w -> id_of(w) != "", held)
end

# タブの id と、束の id は別のもの。同じ名前にしないでおく
tab_id(tab) = get(tab, :id, "")

"手元の一覧のほうも、書いたとおりに直しておく(次の一枚が、それを見せる)"
function set_ws(tabs, ids, ws)
    return [tab_id(t) in ids ? put(t, "values", put(values_of(t), KEY, ws)) : t for t in tabs]
end

"その一枚が、この束の「最後に見ていた」かどうか"
function relast(tab, ws, id)
    if tab_id(tab) == id
        return put(tab, "values", put(values_of(tab), LAST, ws))
    elseif last_of(tab) == ws
        return put(tab, "values", put(values_of(tab), LAST, ""))
    else
        return tab
    end
end

"別の束へ移す一枚。束を書き換えて、古い「最後に見ていた」は落とす"
function move_one(tabs, id, ws)
    return [tab_id(t) == id ? put(t, "values", put(put(values_of(t), KEY, ws), LAST, "")) : t for t in tabs]
end

"この束の目印(最後に見ていた)を、いま選ばれているタブに移す"
mark_last(tabs, ws, id) = [relast(t, ws, id) for t in tabs]

# --- 仕舞う / 見せる -------------------------------------------------------------
# いまの束に合わせて、**変えるものだけ**。束を持っていないタブは、いまの束のもの
# として引き受ける(他のところで開かれたタブが、どこにも属さないまま残らないように)。

mine_here(s, tab) = ws_of(tab) == "" || ws_of(tab) == s.here

set_hidden(tabs, ids, flag) = [tab_id(t) in ids ? put(t, "hidden", flag) : t for t in tabs]

# **したことは、手元の一覧にも書く。** 仕舞った / 見せたことを書かないと、続けて
# もう一度触ったときに「もう仕舞ってある」と思って何もせず、実際には見えたまま
# 残ってしまう(本体は仕舞う・見せるを出来事で教えてくれるけれど、そこまで聞くと
# 自分が起こした分も全部帰ってくるので、こちらで覚える)
function settle(s, adopt)
    if !s.at_window
        return Step(s, [])
    end
    # 束を持っていないタブを引き受けるのは、**中身を持っているタブだけ**。
    # 前の起動から戻ってきたタブは、覚書が結びつく前に一度「持っていないタブ」
    # として見えることがある ── 実機で一度、そうやって別の束のタブを奪った。
    # まだ開かれていないタブは、そのままにしておく(開かれたときに、覚書を持って
    # 現れる)。そのあいだ、そのタブはどの束でも見えている
    orphans = adopt ? [tab_id(t) for t in filter(t -> ws_of(t) == "" && get(t, :discarded, false) !== true, s.tabs)] : []
    owned = set_ws(s.tabs, orphans, s.here)
    show = [tab_id(t) for t in filter(t -> hidden_of(t) && mine_here(s, t), owned)]
    hide = [tab_id(t) for t in filter(t -> !hidden_of(t) && !mine_here(s, t), owned)]
    tabs = set_hidden(set_hidden(owned, show, false), hide, true)
    return Step(
        AppState(tabs = tabs, list = s.list, here = s.here, at_window = true),
        vcat(
            [SetTabValue(id, KEY, s.here) for id in orphans],
            [ShowTab(id) for id in show],
            [HideTab(id) for id in hide]
        )
    )
end

# --- view ---------------------------------------------------------------------

view(s) = Dict("bar" => bar(s), "settings" => pane(s), "menu" => menu(s))

bar(s) = el("hbox", Dict("style" => Style.bar()), [button(s, w) for w in s.list])

button(s, w) = el("toolbarbutton", Dict(
    "id" => "nora-workspaces-" * id_of(w),
    "class" => "toolbarbutton-1",
    "label" => name_of(w),
    "tooltiptext" => t(:switch),
    "checked" => id_of(w) == s.here,
    "style" => Style.button(id_of(w) == s.here),
    "on:command" => Switch(id_of(w))
))

# タブの右クリックの、一行。開くと束が並んで、押したタブがそちらに移る。
# いま開いている束そのものは出さない(移す先が、いま居るところでは意味が無い)
menu(s) = el("menu", Dict("id" => "nora-workspaces-move", "label" => t(:move_to)), [
    el("menupopup", Dict(), [move_row(w) for w in filter(w -> id_of(w) != s.here, s.list)])
])

move_row(w) = el("menuitem", Dict(
    "id" => "nora-workspaces-move-" * id_of(w),
    "label" => name_of(w),
    "on:command" => Move(id_of(w), "")
))

# 設定の一枚。束を足す、名前を変える、消す。
# 消したときにタブをどうするかは、hint にそのまま書いてある
pane(s) = el("div", Dict("style" => Style.pane()), vcat(
    [row(s, w) for w in s.list],
    [
        el("button", Dict("on:click" => Add("")), [t(:add)]),
        el("p", Dict("style" => Style.hint()), [t(:hint)])
    ]
))

row(s, w) = el("div", Dict("style" => Style.row()), [
    el("input", Dict(
        "type" => "text",
        "style" => Style.field(),
        "value" => name_of(w),
        "on:change" => Rename(id_of(w), "")
    )),
    el("button", Dict(
        "disabled" => length(s.list) <= 1,
        "on:click" => Remove(id_of(w))
    ), [t(:remove)])
])

# --- actions ------------------------------------------------------------------

abstract type Action end

"その束に切り替える"
struct Switch <: Action
    ws
end

"束を一つ足す(id は殻に訊く)。何も持たない action にしないのは、形を揃えるため"
struct Add <: Action
    at
end

"訊いた id が返ってきた。一つ目なら、それが最初の束になる"
struct Born <: Action
    uuid
end

"束の名前が打ち直された"
struct Rename <: Action
    ws
    name
end

"このタブを、別の束へ"
struct Move <: Action
    ws
    tab
end

"束を消す。その中のタブは、いまの束に移る(閉じない)"
struct Remove <: Action
    ws
end

"タブのほうで何かが起きた"
struct TabsChanged <: Action
    kind
    tab
    tabs
end

"束の一覧が変わった(もう一つの document が書いたか、about:config で触られたか)"
struct PrefChanged <: Action
    name
    value
end

event_value(a) = get(get(a, :__event, Dict()), :value, "")
# menu の行から来たものには、右クリックされたタブが入っている(殻が popup の
# 開くときに host へ写している)
event_tab(a) = get(get(a, :__event, Dict()), :tab, "")

action_of(a) = begin
    kind = get(a, :__type, "")
    if kind == "Switch"
        Switch(get(a, :ws, ""))
    elseif kind == "Add"
        Add("")
    elseif kind == "Born"
        Born(get(a, :uuid, ""))
    elseif kind == "Rename"
        Rename(get(a, :ws, ""), event_value(a))
    elseif kind == "Move"
        Move(get(a, :ws, ""), event_tab(a))
    elseif kind == "Remove"
        Remove(get(a, :ws, ""))
    elseif kind == "TabsChanged"
        TabsChanged(get(a, :kind, ""), get(a, :tab, Dict()), get(a, :tabs, []))
    elseif kind == "PrefChanged"
        PrefChanged(get(a, :name, ""), get(a, :value, nothing))
    else
        error("workspaces: no action named " * kind)
    end
end

# --- update -------------------------------------------------------------------

struct Step
    state
    effects
end

"束の一覧を書く。両方の document に PrefChanged で戻ってくるので、state はそこで揃う"
write_list(list) = [SetPref(PREF_LIST, list)]

"次の束の名前。既定は番号だけ -- 名前は、入れた人が決めるもの"
next_name(list) = string(length(list) + 1)

# 切り替え。**先に選んで、あとで仕舞う**
update(s::AppState, a::Switch) = begin
    if a.ws == s.here || !has_ws(s.list, a.ws) || !s.at_window
        Step(s, [])
    else
        cur = selected_tab(s.tabs)
        keep = keep_last(s, cur)
        tabs = cur === nothing ? s.tabs : mark_last(s.tabs, s.here, tab_id(cur))
        st = AppState(tabs = tabs, list = s.list, here = a.ws, at_window = true)
        pick = chosen(st, cur)
        done = settle(pick.state, true)
        Step(done.state, vcat(keep, [SetWindowValue(WKEY, a.ws)], pick.effects, done.effects))
    end
end

"いま見ていたタブを、この束の「最後に見ていた」として覚える"
function keep_last(s, cur)
    if cur === nothing || ws_of(cur) != s.here
        return []
    end
    stale = filter(t -> tab_id(t) != tab_id(cur) && last_of(t) == s.here, s.tabs)
    return vcat(
        [ClearTabValue(tab_id(t), LAST) for t in stale],
        [SetTabValue(tab_id(cur), LAST, s.here)]
    )
end

# 新しい束の、どのタブを見せるか。`id` が空で `fresh` なら、一枚も無いので新しく開く。
# effect にして返さないのは、選んだことを **手元の一覧にも書く**ため
# (ここも settle と同じ ── 書かないと、次に触ったときに古い「いまのタブ」で考える)
function pick_tab(s, cur)
    if cur !== nothing && ws_of(cur) == s.here
        return Dict("id" => "", "fresh" => false)
    end
    back = last_tab(s.tabs, s.here)
    if back !== nothing
        return Dict("id" => tab_id(back), "fresh" => false)
    end
    mine = filter(t -> ws_of(t) == s.here, s.tabs)
    if length(mine) > 0
        return Dict("id" => tab_id(first(mine)), "fresh" => false)
    end
    return Dict("id" => "", "fresh" => true)
end

set_selected(tabs, id) = [put(t, "selected", tab_id(t) == id) for t in tabs]

"選ぶところ。effect と、それを書いた一覧の両方を返す"
function chosen(s, cur)
    p = pick_tab(s, cur)
    id = get(p, :id, "")
    if get(p, :fresh, false) === true
        # 新しいタブが開いて、それが選ばれる。どの id になるかは、開いたときの
        # 出来事で教えてもらうまで分からないので、それまでは「いま選ばれているタブは
        # 無い」にしておく(古いままにすると、もう選ばれていないタブを、まだ
        # 選ばれていると思ったまま次の手を打つ)
        return Step(
            AppState(tabs = set_selected(s.tabs, ""), list = s.list, here = s.here,
                     at_window = s.at_window),
            [DoCommand(NEW_TAB)]
        )
    end
    if id == ""
        return Step(s, [])
    end
    return Step(
        AppState(tabs = set_selected(s.tabs, id), list = s.list, here = s.here, at_window = s.at_window),
        [SelectTab(id)]
    )
end

# 押したタブを、別の束へ。移したタブは、もういまの束のものではないので仕舞われる
# ── ただし **それが見ているタブだったら、先にこの束の別のタブへ移る**
# (選ばれているタブは本体が仕舞わないので、順が逆だと仕舞われないまま残る)
update(s::AppState, a::Move) = begin
    if a.tab == "" || a.ws == "" || a.ws == s.here || !has_ws(s.list, a.ws) || !s.at_window
        Step(s, [])
    else
        st = AppState(tabs = move_one(s.tabs, a.tab, a.ws), list = s.list, here = s.here,
                      at_window = true)
        cur = selected_tab(st.tabs)
        pick = (cur !== nothing && tab_id(cur) == a.tab) ? chosen(st, cur) : Step(st, [])
        done = settle(pick.state, true)
        Step(done.state,
             vcat([SetTabValue(a.tab, KEY, a.ws), ClearTabValue(a.tab, LAST)],
                  pick.effects, done.effects))
    end
end

update(s::AppState, a::Add) = Step(s, [Ask(["uuid"], "Born")])

update(s::AppState, a::Born) = begin
    if a.uuid == ""
        Step(s, [])
    else
        Step(s, write_list(vcat(s.list, [Dict("id" => a.uuid, "name" => next_name(s.list))])))
    end
end

update(s::AppState, a::Rename) = begin
    if a.name == ""
        Step(s, [])
    else
        Step(s, write_list([id_of(w) == a.ws ? put(w, "name", a.name) : w for w in s.list]))
    end
end

# 最後の一つは消せない(束がゼロになると、どのタブもどこにも属さなくなる)
update(s::AppState, a::Remove) = begin
    if length(s.list) <= 1
        Step(s, [])
    else
        Step(s, write_list(filter(w -> id_of(w) != a.ws, s.list)))
    end
end

# 一覧が変わった。無くなった束のタブは、**いまの束に移す** -- 束を失っても、
# タブは一枚も失わない
update(s::AppState, a::PrefChanged) = begin
    list = list_of(a.value)
    if length(list) == 0
        Step(AppState(tabs = s.tabs, list = [], here = s.here, at_window = s.at_window), [])
    else
        here = has_ws(list, s.here) ? s.here : id_of(first(list))
        lost = [tab_id(t) for t in filter(t -> ws_of(t) != "" && !has_ws(list, ws_of(t)), s.tabs)]
        st = AppState(tabs = set_ws(s.tabs, lost, here), list = list, here = here,
                      at_window = s.at_window)
        moved = [SetTabValue(id, KEY, here) for id in lost]
        win = (here == s.here || !s.at_window) ? [] : [SetWindowValue(WKEY, here)]
        done = settle(st, true)
        Step(done.state, vcat(moved, win, done.effects))
    end
end

# タブのほうで何かが起きた。することは、どの出来事でも同じ ── 一覧に手元で
# 知っていることを重ねて、いまの束に合わせて辻褄を合わせる。
#
# 束を持っていないタブ(新しく開いたタブ、前の起動で束が付かなかったタブ)を
# 引き受けるのも、仕舞い忘れ・見せ忘れを直すのも、settle が一か所でやっている。
# **出来事ごとに呼ぶ**のが大事で、それは殻から来る一覧が、自分が出したばかりの
# 仕舞う・見せるを、まだ知らないことがあるから ── 一度で合わなくても、次の
# 出来事で合う。
update(s::AppState, a::TabsChanged) = begin
    st = AppState(tabs = remember(s.tabs, a.tabs), list = s.list, here = s.here,
                  at_window = s.at_window)
    # **束を持っていないタブを引き受けるのは、人がそのタブを選んだときだけ。**
    #
    # 「開いた」では引き受けない ── 前の起動から戻ってくるタブも、そこを通る。
    # そしてその瞬間は、覚書もまだ結びついていないし、戻ってきた印(pending)も
    # まだ着ていない(実機で測った)ので、新しく開かれたタブと見分けがつかない。
    # 見分けずに引き受けると、**別の束のタブを奪う**。
    #
    # 選ばれたときなら、そのタブは生きていて覚書も読める。新しく開いたタブは
    # ふつう開いた直後に選ばれるので、そこで引き受けられる。後ろで開いたタブは
    # しばらく束を持たないまま(どの束でも見えている)、次に選ばれたときか、
    # 束を切り替えたときに入る。
    done = settle(st, a.kind == "select")
    Step(done.state, done.effects)
end

# --- the doors ----------------------------------------------------------------

# 窓の覚書が facts に有るかどうかが、「ここは窓か」の合図。設定の頁には窓のタブが
# 無いので、束を作ったり仕舞ったりはしない -- 出すのは設定の一枚だけ
at_window_of(facts) = haskey(facts, :window)

function start(facts)
    tabs = get(facts, :tabs, [])
    list = list_of(get(get(facts, :prefs, Dict()), PREF_LIST, nothing))
    at_window = at_window_of(facts)
    was = get(get(facts, :window, Dict()), WKEY, "")

    if length(list) == 0 && at_window
        # まだ一つも束が無い。id を殻に訊いて、最初の束を作るところから
        # (作るのは窓のほうだけ。設定の頁も一緒に作ると、束が二つできる)
        STATE = AppState(tabs = tabs, list = [], here = was, at_window = at_window)
        return frame(view(STATE), [Ask(["uuid"], "Born")])
    end

    here = length(list) == 0 ? "" : (has_ws(list, was) ? was : id_of(first(list)))
    st = AppState(tabs = tabs, list = list, here = here, at_window = at_window)
    # 起動の直後は、まだ戻ってくる途中のタブが居る。仕舞う / 見せるの辻褄は
    # 合わせるけれど、束を持っていないタブを引き受けるのは、次の出来事から
    done = settle(st, false)
    STATE = done.state
    win = (here == was || !at_window) ? [] : [SetWindowValue(WKEY, here)]
    return frame(view(done.state), vcat(win, done.effects))
end

function dispatch(a)
    st = update(STATE, action_of(a))
    STATE = st.state
    return frame(view(STATE), st.effects)
end
workspaces/strings.toml · 17 lines
# 字は、鍵で書いて、どのロケールにするかは殻が決める(std の t(:鍵))。
# 束の名前そのものは、ここに無い -- 入れた人が決めるもので、pref に住んでいる。

[en]
add = "Add a workspace"
remove = "Remove"
hint = "Removing a workspace moves its tabs into the one you are in. No tab is closed."
switch = "Switch to this workspace"
move_to = "Move tab to workspace"

[ja]
add = "束を足す"
remove = "消す"
hint = "束を消すと、その中のタブは、いま開いている束に移ります。タブは一枚も閉じません。"
switch = "この束に切り替える"
move_to = "このタブを別の束へ"

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.