stream_weaver 0.3.0
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.
- checksums.yaml +7 -0
- data/.beads/.gitignore +77 -0
- data/.beads/README.md +81 -0
- data/.beads/config.yaml +54 -0
- data/.beads/metadata.json +7 -0
- data/.rspec +3 -0
- data/AGENTS.md +91 -0
- data/CHANGELOG.md +294 -0
- data/CLAUDE.md +67 -0
- data/LICENSE.txt +21 -0
- data/README.md +565 -0
- data/Rakefile +13 -0
- data/assets/streamweaver-hero.jpg +0 -0
- data/bench/baselines/ledger.rb +58 -0
- data/bench/baselines/warroom.rb +51 -0
- data/bench/fixtures/ledger.rb +76 -0
- data/bench/fixtures/warroom.rb +60 -0
- data/bench/results/6daf8b8.md +102 -0
- data/bench/results/c8d749b.md +102 -0
- data/bench/results/eb06ab9.md +102 -0
- data/bench/run.rb +151 -0
- data/bench/support.rb +84 -0
- data/copy_this/dto_run_sample_run_7631.html +220 -0
- data/docs/SERVICE_MODE.md +302 -0
- data/docs/architecture/how_streamweaver_works.md +772 -0
- data/docs/blog/2026-01-16-streamweaver-introduction.md +515 -0
- data/docs/bug-2026-08-31-canvas-bridge-socket-collision.md +83 -0
- data/docs/canvas-ipc-session-summary.md +127 -0
- data/docs/canvas-panel-workflow.md +172 -0
- data/docs/canvas-read.md +147 -0
- data/docs/canvas-roadmap.md +275 -0
- data/docs/case-studies/2026-09-03-didx-canvas-read-shelf.md +147 -0
- data/docs/claude-code-companion-skill-spec.md +567 -0
- data/docs/components_reference.md +1223 -0
- data/docs/crud-patterns.md +265 -0
- data/docs/endpoints.md +122 -0
- data/docs/for_llms.md +1 -0
- data/docs/form-patterns.md +165 -0
- data/docs/frontend-only.md +169 -0
- data/docs/html-artifact-audit.md +238 -0
- data/docs/ideas/2025-01-01-as-a-service.md +203 -0
- data/docs/ideas/2026-01-16-charm-tui-exploration.md +542 -0
- data/docs/ideas/2026-08-27-static-doc-shelf-export.md +153 -0
- data/docs/ideas/dhh-review-future-refactors.md +142 -0
- data/docs/opal-jamstack.md +47 -0
- data/docs/opal-npm-direction.md +184 -0
- data/docs/opal-spike-findings.md +194 -0
- data/docs/plans/2025-12-16-form-blocks-design.md +124 -0
- data/docs/plans/2025-12-30-examples-browser-design.md +152 -0
- data/docs/plans/2026-01-02-charts-design.md +288 -0
- data/docs/plans/2026-01-10-canvas-ipc-design.md +247 -0
- data/docs/plans/2026-01-19-cabinet-control-components.md +176 -0
- data/docs/plans/2026-01-26-gem-release-and-panel.md +140 -0
- data/docs/plans/canvas-claude-project-design.md +238 -0
- data/docs/plans/canvas-doc-location-and-discovery.md +32 -0
- data/docs/plans/org-doc-preview-surfaces.md +293 -0
- data/docs/plans/shadcn-polish-plan.md +417 -0
- data/docs/porting-artifacts.md +252 -0
- data/docs/reference/agent-skills-comparison.md +369 -0
- data/docs/reference/travel-state-prd.artifact.html +780 -0
- data/docs/research/2026-08-17-hotwire-alike-landscape.md +268 -0
- data/docs/research/2026-08-17-hotwire-concept-map.md +404 -0
- data/docs/research/2026-08-22-lazy-fragments-trigger-decision.md +246 -0
- data/docs/research/2026-08-22-learnhotwire-syllabus-coverage.md +239 -0
- data/docs/research/frontend-only-matrix.md +278 -0
- data/docs/research/streamweaver-way-spike-findings.md +529 -0
- data/docs/resource-dsl.md +342 -0
- data/docs/routing.md +302 -0
- data/docs/ruby-ui-comparison.md +319 -0
- data/docs/shared-dsl-fragments.md +356 -0
- data/docs/streamweaver-for-ai-agents.md +401 -0
- data/docs/streamweaver-frontend-vision.md +123 -0
- data/docs/streamweaver_canvas/glimmer_initial_final_layer.rb +77 -0
- data/docs/streamweaver_canvas/save_example.rb +9 -0
- data/docs/templates.md +213 -0
- data/docs/testing.md +155 -0
- data/docs/theming-hooks.md +281 -0
- data/docs/tutorials/the-streamweaver-way.md +882 -0
- data/docs/university/capability-inventory.md +137 -0
- data/docs/university/dependency-survey.md +114 -0
- data/docs/university/design-spec.md +217 -0
- data/docs/university/mockups/code-block-doc-theme-dark.png +0 -0
- data/docs/university/mockups/code-block-doc-theme-light.png +0 -0
- data/docs/university/mockups/course-list-dark.png +0 -0
- data/docs/university/mockups/course-list-light.png +0 -0
- data/docs/university/mockups/course_canvas_mockup.rb +741 -0
- data/docs/university/mockups/step-screen-dark.png +0 -0
- data/docs/university/mockups/step-screen-light.png +0 -0
- data/docs/university/roadmap.md +72 -0
- data/docs/university/send-to-coworker.md +77 -0
- data/docs/university/worker-session-mining.md +109 -0
- data/docs/visual/browser-rendering-spike.md +173 -0
- data/docs/visual/builder-visual-plan-analysis.md +378 -0
- data/docs/visual/sw-plan-format-exploration.md +511 -0
- data/docs/visual/sw-plan-rendering-deep-dive.md +453 -0
- data/docs/visual-skills/PROGRESS.md +60 -0
- data/docs/visual-skills/SESSION-CONTEXT.md +207 -0
- data/docs/visual-skills/analysis/components.md +317 -0
- data/docs/visual-skills/analysis/overlap.md +360 -0
- data/docs/visual-skills/analysis/pi-design-deck.md +1102 -0
- data/docs/visual-skills/analysis/streamweaver-inventory.md +93 -0
- data/docs/visual-skills/analysis/unified-specs.feature +1003 -0
- data/docs/visual-skills/analysis/visual-explainer.md +964 -0
- data/docs/visual-skills/blog/blog-series-outline.md +39 -0
- data/docs/visual-skills/blog/token-efficiency.md +73 -0
- data/docs/visual-skills/design/architecture.md +1725 -0
- data/docs/visual-skills/design/codex-review.md +340 -0
- data/docs/visual-skills/design/dhh-review.md +209 -0
- data/docs/visual-skills/design/evolution.md +100 -0
- data/docs/visual-skills/design/gemini-review.md +55 -0
- data/docs/visual-skills/design/review-synthesis.md +149 -0
- data/docs/visual-skills/implementation/STATE.md +22 -0
- data/docs/visual-skills/implementation/plan.md +347 -0
- data/docs/visual-skills/implementation/spike-findings.md +237 -0
- data/docs/visual-skills/implementation/tasks.md +702 -0
- data/docs/visual-skills/lessons-learned/process.md +145 -0
- data/examples/README.md +96 -0
- data/examples/advanced/all_components.rb +177 -0
- data/examples/advanced/examples_browser.rb +394 -0
- data/examples/advanced/teachables_browser.rb +261 -0
- data/examples/advanced/theme_tweaker.rb +366 -0
- data/examples/advanced/tutorial.rb +1528 -0
- data/examples/agentic/agentic_form.rb +30 -0
- data/examples/agentic/agentic_form_autoclose.rb +30 -0
- data/examples/agentic/cultivation_tracker.rb +69 -0
- data/examples/basic/hello_world.rb +22 -0
- data/examples/basic/opal_tabs_table.rb +44 -0
- data/examples/basic/todo_list.rb +39 -0
- data/examples/button_loading_test.rb +36 -0
- data/examples/canvas/mermaid_canvas_demo.sh +151 -0
- data/examples/charts/bar_chart_demo.rb +57 -0
- data/examples/charts/line_chart_demo.rb +73 -0
- data/examples/charts/pie_area_demo.rb +87 -0
- data/examples/charts/stacked_bar_chart_demo.rb +69 -0
- data/examples/claude_code/README.md +85 -0
- data/examples/claude_code/codebreaker/.claude/commands/infiltrate.md +632 -0
- data/examples/claude_code/codebreaker/.claude/settings.json +11 -0
- data/examples/claude_code/codebreaker/.claude/settings.local.json +17 -0
- data/examples/claude_code/codebreaker/.claude/skills/infiltrate.md +0 -0
- data/examples/claude_code/codebreaker/README.md +213 -0
- data/examples/claude_code/tutorial/.claude/commands/learn.md +453 -0
- data/examples/claude_code/tutorial/README.md +102 -0
- data/examples/claude_code/verification_flow/.claude/commands/verify.md +171 -0
- data/examples/claude_code/verification_flow/README.md +126 -0
- data/examples/components/annotated_code_demo.rb +54 -0
- data/examples/components/callout_demo.rb +33 -0
- data/examples/components/checkbox_group_demo.rb +46 -0
- data/examples/components/design_review.css +468 -0
- data/examples/components/design_review_demo.rb +44 -0
- data/examples/components/design_review_dsl.rb +300 -0
- data/examples/components/diff_block_demo.rb +112 -0
- data/examples/components/events_demo.rb +184 -0
- data/examples/components/form_demo.rb +62 -0
- data/examples/components/lesson_demo.rb +53 -0
- data/examples/components/markdown_demo.rb +110 -0
- data/examples/components/mermaid_demo.rb +166 -0
- data/examples/components/pareto_set.rb +64 -0
- data/examples/components/prd_demo.rb +21 -0
- data/examples/components/prd_dsl.rb +385 -0
- data/examples/components/quiz_demo.rb +47 -0
- data/examples/components/run_viewer_demo.rb +100 -0
- data/examples/components/score_and_collapsible_demo.rb +82 -0
- data/examples/components/select_stale_value_smoke_test.rb +39 -0
- data/examples/components/table_demo.rb +247 -0
- data/examples/components/timer_with_state_demo.rb +58 -0
- data/examples/components/todo_due_dates.rb +63 -0
- data/examples/components/uat_gaps_demo.rb +112 -0
- data/examples/dashboard/feed_simulator.rb +60 -0
- data/examples/dashboard/live_dashboard.rb +80 -0
- data/examples/dashboard_components.rb +108 -0
- data/examples/deferred_fragments_demo.rb +62 -0
- data/examples/generate_more_spike/README.md +63 -0
- data/examples/generate_more_spike/app.rb +441 -0
- data/examples/git_health.sh +401 -0
- data/examples/layout/layout_components_demo.rb +174 -0
- data/examples/layout/modal_demo.rb +215 -0
- data/examples/layout/navigation_demo.rb +227 -0
- data/examples/layout/route_tabs_demo.rb +52 -0
- data/examples/layout/routing_demo.rb +52 -0
- data/examples/layout/scroll_box_demo.rb +154 -0
- data/examples/lazy_fragments_demo.rb +151 -0
- data/examples/my_todos/README.md +29 -0
- data/examples/my_todos/my_todos.rb +312 -0
- data/examples/my_todos/store.rb +86 -0
- data/examples/opal/reactive_demo.rb +62 -0
- data/examples/opal/scenarios/s1_counter.rb +15 -0
- data/examples/opal/scenarios/s2_search_filter.rb +17 -0
- data/examples/opal/scenarios/s3_sibling_tabs.rb +25 -0
- data/examples/opal/scenarios/s4_shopping_cart.rb +34 -0
- data/examples/opal/scenarios/s5_watch.rb +39 -0
- data/examples/opal/scenarios/s6_on_start.rb +20 -0
- data/examples/opal/scenarios/s7_wizard.rb +33 -0
- data/examples/opal/scenarios/s8_loan_calculator.rb +21 -0
- data/examples/opal/scenarios/s9_dashboard.rb +27 -0
- data/examples/operations_dashboard_demo.rb +167 -0
- data/examples/panel_demo.sh +447 -0
- data/examples/parity/assets/tyrion_slice.css +86 -0
- data/examples/parity/rivet_people_slice.rb +182 -0
- data/examples/parity/tyrion_components.css +777 -0
- data/examples/parity/tyrion_warroom_components.rb +414 -0
- data/examples/parity/tyrion_warroom_slice.rb +549 -0
- data/examples/puma_dev/README.md +124 -0
- data/examples/puma_dev/config.ru +34 -0
- data/examples/puma_dev/standalone_app.rb +34 -0
- data/examples/scaffolding/blog.rb +49 -0
- data/examples/scaffolding/utf_lite.rb +105 -0
- data/examples/styling/feedback_demo.rb +229 -0
- data/examples/styling/style_showcase.rb +172 -0
- data/examples/styling/theme_demo.rb +508 -0
- data/examples/timer_health_checker.rb +111 -0
- data/examples/timer_showcase.rb +131 -0
- data/examples/tutorials/render_markdown.rb +112 -0
- data/examples/tutorials/streamweaver_way_tutorial.rb +44 -0
- data/examples/tutorials/tutorial_content.rb +1082 -0
- data/examples/visual_skills/design_deck_demo.rb +314 -0
- data/examples/visual_skills/explainer_demo.rb +499 -0
- data/exe/streamweaver +9 -0
- data/gsd/ROADMAP-1.0.md +167 -0
- data/gsd/STATE.md +13 -0
- data/gsd/research/market-positioning-research.md +139 -0
- data/gsd/research/production-patterns-research.md +270 -0
- data/gsd/research/repo-audit-1.0.md +274 -0
- data/lib/stream_weaver/action_token.rb +58 -0
- data/lib/stream_weaver/adapter/alpinejs.rb +8249 -0
- data/lib/stream_weaver/adapter/base.rb +591 -0
- data/lib/stream_weaver/adapter/opal.rb +334 -0
- data/lib/stream_weaver/adapter/static.rb +1118 -0
- data/lib/stream_weaver/admin.rb +176 -0
- data/lib/stream_weaver/app.rb +1821 -0
- data/lib/stream_weaver/assets/js/sw-copy.js +50 -0
- data/lib/stream_weaver/assets/js/sw-heredoc-rewrite.js +68 -0
- data/lib/stream_weaver/assets/js/sw-keyboard.js +165 -0
- data/lib/stream_weaver/assets/js/sw-mermaid-zoom.js +621 -0
- data/lib/stream_weaver/assets/js/sw-route-tabs.js +53 -0
- data/lib/stream_weaver/assets/js/sw-sidebar-toc.js +116 -0
- data/lib/stream_weaver/assets/js/sw-slide-nav.js +103 -0
- data/lib/stream_weaver/canvas/bridge.rb +239 -0
- data/lib/stream_weaver/canvas/bridge_server.rb +646 -0
- data/lib/stream_weaver/canvas/client.rb +298 -0
- data/lib/stream_weaver/canvas/doc_roots.rb +233 -0
- data/lib/stream_weaver/canvas/doc_store.rb +252 -0
- data/lib/stream_weaver/canvas/gist_publisher.rb +264 -0
- data/lib/stream_weaver/canvas/gist_save_handler.rb +89 -0
- data/lib/stream_weaver/canvas/gist_store.rb +135 -0
- data/lib/stream_weaver/canvas/helpers.rb +109 -0
- data/lib/stream_weaver/canvas/history.rb +90 -0
- data/lib/stream_weaver/canvas/protocol.rb +86 -0
- data/lib/stream_weaver/canvas/reader.rb +853 -0
- data/lib/stream_weaver/canvas/save_doc_widget.rb +457 -0
- data/lib/stream_weaver/canvas/scroll_top_hint.rb +21 -0
- data/lib/stream_weaver/canvas/session.rb +132 -0
- data/lib/stream_weaver/cli.rb +3235 -0
- data/lib/stream_weaver/component_assets.rb +70 -0
- data/lib/stream_weaver/component_registry.rb +67 -0
- data/lib/stream_weaver/component_renderer.rb +48 -0
- data/lib/stream_weaver/components/annotated_code.rb +53 -0
- data/lib/stream_weaver/components/api_endpoint.rb +42 -0
- data/lib/stream_weaver/components/callout.rb +59 -0
- data/lib/stream_weaver/components/chart.rb +84 -0
- data/lib/stream_weaver/components/code_block.rb +77 -0
- data/lib/stream_weaver/components/comparison.rb +39 -0
- data/lib/stream_weaver/components/decision.rb +38 -0
- data/lib/stream_weaver/components/deck/close_overlay.rb +84 -0
- data/lib/stream_weaver/components/deck/confirmation_bar.rb +60 -0
- data/lib/stream_weaver/components/deck/deck_option.rb +61 -0
- data/lib/stream_weaver/components/deck/deck_slide.rb +77 -0
- data/lib/stream_weaver/components/deck/deck_state.rb +469 -0
- data/lib/stream_weaver/components/deck/deck_summary.rb +73 -0
- data/lib/stream_weaver/components/deck/design_deck.rb +60 -0
- data/lib/stream_weaver/components/deck/generate_more_controls.rb +84 -0
- data/lib/stream_weaver/components/deck/model_selector.rb +84 -0
- data/lib/stream_weaver/components/deck/skeleton_placeholder.rb +36 -0
- data/lib/stream_weaver/components/diff_block.rb +125 -0
- data/lib/stream_weaver/components/doc_header.rb +57 -0
- data/lib/stream_weaver/components/image_block.rb +70 -0
- data/lib/stream_weaver/components/implementation_map.rb +33 -0
- data/lib/stream_weaver/components/keyboard_shortcuts.rb +94 -0
- data/lib/stream_weaver/components/kpi_dashboard.rb +78 -0
- data/lib/stream_weaver/components/mermaid.rb +79 -0
- data/lib/stream_weaver/components/pipeline.rb +63 -0
- data/lib/stream_weaver/components/sidebar_toc.rb +46 -0
- data/lib/stream_weaver/components/slide_container.rb +141 -0
- data/lib/stream_weaver/components/timeline_event.rb +58 -0
- data/lib/stream_weaver/components/wireframe.rb +29 -0
- data/lib/stream_weaver/components/wireframe_block.rb +34 -0
- data/lib/stream_weaver/components.rb +2711 -0
- data/lib/stream_weaver/css.rb +230 -0
- data/lib/stream_weaver/dev_fallback_overlay.rb +61 -0
- data/lib/stream_weaver/display_dsl.rb +1004 -0
- data/lib/stream_weaver/export/html_exporter.rb +478 -0
- data/lib/stream_weaver/feed.rb +34 -0
- data/lib/stream_weaver/feed_builder.rb +31 -0
- data/lib/stream_weaver/fonts.rb +33 -0
- data/lib/stream_weaver/interaction_runner.rb +487 -0
- data/lib/stream_weaver/iterm.rb +460 -0
- data/lib/stream_weaver/layout_registry.rb +92 -0
- data/lib/stream_weaver/opal/bridge.rb +52 -0
- data/lib/stream_weaver/opal/builder.rb +193 -0
- data/lib/stream_weaver/opal/env.rb +29 -0
- data/lib/stream_weaver/opal/reactive_state.rb +75 -0
- data/lib/stream_weaver/opal/regexp_anchor_patch.rb +62 -0
- data/lib/stream_weaver/opal/renderer.rb +77 -0
- data/lib/stream_weaver/opal/runtime.rb +250 -0
- data/lib/stream_weaver/opal/shell.rb +145 -0
- data/lib/stream_weaver/opal/string_bridge.rb +50 -0
- data/lib/stream_weaver/opal/stubs/diff.min.js +1 -0
- data/lib/stream_weaver/opal/stubs/digest.rb +15 -0
- data/lib/stream_weaver/opal/stubs/marked.umd.js +79 -0
- data/lib/stream_weaver/opal/stubs/md5.rb +3 -0
- data/lib/stream_weaver/opal/stubs/morphdom.min.js +775 -0
- data/lib/stream_weaver/opal/stubs/prism-tomorrow.min.css +1 -0
- data/lib/stream_weaver/opal/stubs/prism.min.js +1967 -0
- data/lib/stream_weaver/opal_entry.rb +136 -0
- data/lib/stream_weaver/org/inline.rb +95 -0
- data/lib/stream_weaver/org/reader.rb +561 -0
- data/lib/stream_weaver/org/recording_context.rb +86 -0
- data/lib/stream_weaver/org/source_splitter.rb +63 -0
- data/lib/stream_weaver/org/writer.rb +300 -0
- data/lib/stream_weaver/page_shell.rb +530 -0
- data/lib/stream_weaver/portfile.rb +79 -0
- data/lib/stream_weaver/pushable.rb +42 -0
- data/lib/stream_weaver/resource/default_views.rb +111 -0
- data/lib/stream_weaver/resource/field_input.rb +25 -0
- data/lib/stream_weaver/resource/state_keys.rb +16 -0
- data/lib/stream_weaver/resource/store.rb +18 -0
- data/lib/stream_weaver/resource.rb +104 -0
- data/lib/stream_weaver/server.rb +1471 -0
- data/lib/stream_weaver/service.rb +1240 -0
- data/lib/stream_weaver/service_client.rb +104 -0
- data/lib/stream_weaver/session_store.rb +186 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/SKILL.md +66 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/examples/canvas-safe-showcase.rb +98 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/actions-and-buttons.md +47 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/charts-and-diagrams.md +38 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/deck.md +32 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/inputs-and-forms.md +65 -0
- data/lib/stream_weaver/skills/streamweaver-canvas-safe/references/tabs-and-navigation.md +53 -0
- data/lib/stream_weaver/skills/streamweaver-doc-builder/SKILL.md +298 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/SKILL.md +130 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.css +468 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example.rb +39 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/design-review-example_dsl.rb +300 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example.rb +24 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/examples/doc-parity-example_dsl.rb +385 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/checkpoints-and-forms.md +28 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/cleanup-and-panel.md +44 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/example-gallery.md +8 -0
- data/lib/stream_weaver/skills/streamweaver-visual-companion/references/persistence.md +58 -0
- data/lib/stream_weaver/skills/streamweaver-way/SKILL.md +396 -0
- data/lib/stream_weaver/skills/visual-plan/SKILL.md +201 -0
- data/lib/stream_weaver/skills/visual-recap/SKILL.md +244 -0
- data/lib/stream_weaver/streamer.rb +63 -0
- data/lib/stream_weaver/templates/choices.rb +149 -0
- data/lib/stream_weaver/templates/code.rb +157 -0
- data/lib/stream_weaver/templates/confirm.rb +110 -0
- data/lib/stream_weaver/templates/diff.rb +195 -0
- data/lib/stream_weaver/templates/info.rb +142 -0
- data/lib/stream_weaver/templates/table.rb +169 -0
- data/lib/stream_weaver/templates/wizard.rb +271 -0
- data/lib/stream_weaver/theme/auto_mode.rb +116 -0
- data/lib/stream_weaver/theme/presets.rb +555 -0
- data/lib/stream_weaver/theme.rb +639 -0
- data/lib/stream_weaver/university/canvas.rb +924 -0
- data/lib/stream_weaver/university/course.rb +699 -0
- data/lib/stream_weaver/university/demos/counter.rb +33 -0
- data/lib/stream_weaver/university/demos/dashboard.rb +147 -0
- data/lib/stream_weaver/university/demos/decision_form.rb +143 -0
- data/lib/stream_weaver/university/demos.rb +50 -0
- data/lib/stream_weaver/university/listener.rb +467 -0
- data/lib/stream_weaver/university/progress.rb +201 -0
- data/lib/stream_weaver/university/runner.rb +138 -0
- data/lib/stream_weaver/university/scripts/growing_doc.rb +609 -0
- data/lib/stream_weaver/university/scripts/growing_doc_state.rb +91 -0
- data/lib/stream_weaver/utils.rb +31 -0
- data/lib/stream_weaver/version.rb +5 -0
- data/lib/stream_weaver/views/canvas/reader_layout.erb +800 -0
- data/lib/stream_weaver/views.rb +3940 -0
- data/lib/stream_weaver.rb +122 -0
- data/llms.txt +1269 -0
- data/sig/stream_weaver.rbs +4 -0
- metadata +598 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require_relative 'protocol'
|
|
7
|
+
|
|
8
|
+
module StreamWeaver
|
|
9
|
+
module Canvas
|
|
10
|
+
# Client for communicating with the Canvas Bridge via Unix socket.
|
|
11
|
+
# Used by CLI commands to send messages to the bridge.
|
|
12
|
+
class Client
|
|
13
|
+
class NotRunningError < StandardError; end
|
|
14
|
+
class ConnectionError < StandardError; end
|
|
15
|
+
|
|
16
|
+
SOCKET_PATH = File.expand_path('~/.streamweaver/canvas.sock')
|
|
17
|
+
PID_FILE_PATH = File.expand_path('~/.streamweaver/canvas.pid')
|
|
18
|
+
DEFAULT_TIMEOUT = 300 # 5 minutes
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# STREAMWEAVER_CANVAS_SOCKET / _PID redirect the whole bridge --
|
|
22
|
+
# socket, pid file, and (because the bridge is spawned with the
|
|
23
|
+
# environment it inherits) the server half too. This exists so a
|
|
24
|
+
# test, or a deliberately separate instance, can run its own bridge
|
|
25
|
+
# without stopping the one the developer has open in a browser and
|
|
26
|
+
# taking every live canvas session with it.
|
|
27
|
+
def socket_path
|
|
28
|
+
ENV['STREAMWEAVER_CANVAS_SOCKET'] || SOCKET_PATH
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def pid_file_path
|
|
32
|
+
ENV['STREAMWEAVER_CANVAS_PID'] || PID_FILE_PATH
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Check if the bridge process is running
|
|
36
|
+
# @return [Boolean]
|
|
37
|
+
def bridge_running?
|
|
38
|
+
return false unless File.exist?(pid_file_path)
|
|
39
|
+
|
|
40
|
+
content = File.read(pid_file_path)
|
|
41
|
+
# Parse pid=VALUE format
|
|
42
|
+
pid = content[/pid=(\d+)/, 1]&.to_i || content.strip.to_i
|
|
43
|
+
return false if pid.zero?
|
|
44
|
+
|
|
45
|
+
# Check if process exists
|
|
46
|
+
Process.kill(0, pid)
|
|
47
|
+
true
|
|
48
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
49
|
+
# Process not found or no permission
|
|
50
|
+
cleanup_stale_files
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Clean up stale PID and socket files
|
|
55
|
+
def cleanup_stale_files
|
|
56
|
+
File.delete(pid_file_path) if File.exist?(pid_file_path)
|
|
57
|
+
File.delete(socket_path) if File.exist?(socket_path)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Send a message to the bridge and return the response
|
|
61
|
+
# @param message [Hash] Message to send
|
|
62
|
+
# @param timeout [Integer] Timeout in seconds
|
|
63
|
+
# @return [Hash, nil] Response message or nil
|
|
64
|
+
def send_message(message, timeout: 5)
|
|
65
|
+
raise NotRunningError, "Canvas bridge is not running" unless bridge_running?
|
|
66
|
+
|
|
67
|
+
socket = UNIXSocket.new(socket_path)
|
|
68
|
+
socket.write(Protocol.encode(message))
|
|
69
|
+
|
|
70
|
+
# Read response with timeout
|
|
71
|
+
if IO.select([socket], nil, nil, timeout)
|
|
72
|
+
buffer = ''
|
|
73
|
+
while (chunk = socket.read_nonblock(4096, exception: false))
|
|
74
|
+
break if chunk == :wait_readable
|
|
75
|
+
buffer += chunk
|
|
76
|
+
break if buffer.include?("\n")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
messages, _ = Protocol.parse_buffer(buffer)
|
|
80
|
+
messages.first
|
|
81
|
+
else
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
rescue Errno::ECONNREFUSED, Errno::ENOENT => e
|
|
85
|
+
raise ConnectionError, "Cannot connect to canvas bridge: #{e.message}"
|
|
86
|
+
ensure
|
|
87
|
+
socket&.close
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Send a message and wait for a specific event type
|
|
91
|
+
# @param message [Hash] Message to send
|
|
92
|
+
# @param event_type [String] Event type to wait for
|
|
93
|
+
# @param timeout [Integer] Timeout in seconds
|
|
94
|
+
# @return [Hash, nil] Event message or nil
|
|
95
|
+
def send_and_wait(message, event_type:, timeout: DEFAULT_TIMEOUT)
|
|
96
|
+
raise NotRunningError, "Canvas bridge is not running" unless bridge_running?
|
|
97
|
+
|
|
98
|
+
socket = UNIXSocket.new(socket_path)
|
|
99
|
+
socket.write(Protocol.encode(message))
|
|
100
|
+
|
|
101
|
+
start_time = Time.now
|
|
102
|
+
buffer = ''
|
|
103
|
+
|
|
104
|
+
loop do
|
|
105
|
+
remaining = timeout - (Time.now - start_time)
|
|
106
|
+
break if remaining <= 0
|
|
107
|
+
|
|
108
|
+
if IO.select([socket], nil, nil, [remaining, 1].min)
|
|
109
|
+
chunk = socket.read_nonblock(4096, exception: false)
|
|
110
|
+
next if chunk == :wait_readable
|
|
111
|
+
buffer += chunk
|
|
112
|
+
|
|
113
|
+
messages, buffer = Protocol.parse_buffer(buffer)
|
|
114
|
+
messages.each do |msg|
|
|
115
|
+
return msg if msg[:type] == event_type
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
nil
|
|
121
|
+
rescue Errno::ECONNREFUSED, Errno::ENOENT => e
|
|
122
|
+
raise ConnectionError, "Cannot connect to canvas bridge: #{e.message}"
|
|
123
|
+
ensure
|
|
124
|
+
socket&.close
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Hold ONE connection open and yield every event for `name` until the
|
|
128
|
+
# bridge closes it or the block breaks. This is what a long-lived
|
|
129
|
+
# listener wants: `send_and_wait` takes a single event and closes the
|
|
130
|
+
# socket, so a loop built on it is deaf for the whole gap between
|
|
131
|
+
# handling one click and reopening -- which is exactly when the
|
|
132
|
+
# re-push is happening and the user is most likely to click again.
|
|
133
|
+
#
|
|
134
|
+
# Events are filtered by session name because the bridge forwards
|
|
135
|
+
# every session's events to every connected client
|
|
136
|
+
# (docs/bug-2026-08-24-canvas-bridge-broadcasts-no-session-filtering.md).
|
|
137
|
+
#
|
|
138
|
+
# @param name [String, nil] session to filter on; nil yields all
|
|
139
|
+
# @yield [Hash] each event message
|
|
140
|
+
def each_event(name = nil)
|
|
141
|
+
raise NotRunningError, "Canvas bridge is not running" unless bridge_running?
|
|
142
|
+
|
|
143
|
+
socket = UNIXSocket.new(socket_path)
|
|
144
|
+
buffer = ''
|
|
145
|
+
|
|
146
|
+
loop do
|
|
147
|
+
# readpartial blocks until there are bytes and raises at EOF --
|
|
148
|
+
# no poll interval to burn, and no third spelling of "closed".
|
|
149
|
+
# A peer reset is the same event as a clean close as far as this
|
|
150
|
+
# loop is concerned: the bridge is gone, so return and let the
|
|
151
|
+
# caller decide whether to reconnect.
|
|
152
|
+
buffer += socket.readpartial(4096)
|
|
153
|
+
|
|
154
|
+
messages, buffer = Protocol.parse_buffer(buffer)
|
|
155
|
+
messages.each do |msg|
|
|
156
|
+
next unless msg[:type] == 'event'
|
|
157
|
+
next if name && msg[:name] != name
|
|
158
|
+
|
|
159
|
+
yield msg
|
|
160
|
+
end
|
|
161
|
+
rescue EOFError, Errno::ECONNRESET
|
|
162
|
+
break
|
|
163
|
+
end
|
|
164
|
+
rescue Errno::ECONNREFUSED, Errno::ENOENT => e
|
|
165
|
+
raise ConnectionError, "Cannot connect to canvas bridge: #{e.message}"
|
|
166
|
+
ensure
|
|
167
|
+
socket&.close
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Start the bridge process if not running
|
|
171
|
+
# @return [Hash] { pid: Integer, port: Integer }
|
|
172
|
+
def ensure_bridge_running
|
|
173
|
+
if bridge_running?
|
|
174
|
+
info = read_bridge_info
|
|
175
|
+
# Verify HTTP server is actually responding
|
|
176
|
+
if info && info[:port] && http_healthy?(info[:port])
|
|
177
|
+
return info
|
|
178
|
+
end
|
|
179
|
+
# Bridge process exists but HTTP not responding - restart
|
|
180
|
+
cleanup_stale_files
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
start_bridge
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Check if HTTP server is responding on given port
|
|
187
|
+
def http_healthy?(port)
|
|
188
|
+
require 'net/http'
|
|
189
|
+
uri = URI("http://127.0.0.1:#{port}/health")
|
|
190
|
+
response = Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http|
|
|
191
|
+
http.get(uri.path)
|
|
192
|
+
end
|
|
193
|
+
response.is_a?(Net::HTTPSuccess)
|
|
194
|
+
rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, Net::OpenTimeout, Net::ReadTimeout
|
|
195
|
+
false
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Start the bridge process
|
|
199
|
+
# @return [Hash] { pid: Integer, port: Integer }
|
|
200
|
+
def start_bridge
|
|
201
|
+
require_relative 'bridge_server'
|
|
202
|
+
|
|
203
|
+
# Ensure directory exists
|
|
204
|
+
FileUtils.mkdir_p(File.dirname(pid_file_path))
|
|
205
|
+
|
|
206
|
+
# Get the lib path
|
|
207
|
+
lib_path = File.expand_path('../..', __dir__)
|
|
208
|
+
|
|
209
|
+
# Create startup script
|
|
210
|
+
script = <<~RUBY
|
|
211
|
+
$LOAD_PATH.unshift('#{lib_path}')
|
|
212
|
+
require 'stream_weaver'
|
|
213
|
+
require 'stream_weaver/canvas/bridge_server'
|
|
214
|
+
StreamWeaver::Canvas::BridgeServer.run!
|
|
215
|
+
RUBY
|
|
216
|
+
|
|
217
|
+
# Write script to temp file
|
|
218
|
+
script_file = File.join(File.dirname(pid_file_path), 'canvas_start.rb')
|
|
219
|
+
File.write(script_file, script)
|
|
220
|
+
|
|
221
|
+
# Log file
|
|
222
|
+
log_file = File.join(File.dirname(pid_file_path), 'canvas.log')
|
|
223
|
+
|
|
224
|
+
# Spawn the bridge process
|
|
225
|
+
pid = spawn(
|
|
226
|
+
RbConfig.ruby, script_file,
|
|
227
|
+
out: [log_file, 'a'],
|
|
228
|
+
err: [log_file, 'a'],
|
|
229
|
+
pgroup: true
|
|
230
|
+
)
|
|
231
|
+
Process.detach(pid)
|
|
232
|
+
|
|
233
|
+
# Wait for both socket AND HTTP server to be ready
|
|
234
|
+
30.times do
|
|
235
|
+
if File.exist?(socket_path) && File.exist?(pid_file_path)
|
|
236
|
+
info = read_bridge_info
|
|
237
|
+
if info && info[:port]
|
|
238
|
+
# Probe HTTP server to confirm it's actually listening
|
|
239
|
+
begin
|
|
240
|
+
require 'net/http'
|
|
241
|
+
uri = URI("http://127.0.0.1:#{info[:port]}/health")
|
|
242
|
+
response = Net::HTTP.get_response(uri)
|
|
243
|
+
return info if response.is_a?(Net::HTTPSuccess)
|
|
244
|
+
rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL
|
|
245
|
+
# Server not ready yet
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
sleep 0.2
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Fallback - return what we have
|
|
253
|
+
read_bridge_info || { pid: pid, port: BridgeServer::DEFAULT_PORT }
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Read bridge info from PID file
|
|
257
|
+
# @return [Hash] { pid: Integer, port: Integer }
|
|
258
|
+
def read_bridge_info
|
|
259
|
+
return nil unless File.exist?(pid_file_path)
|
|
260
|
+
|
|
261
|
+
content = File.read(pid_file_path)
|
|
262
|
+
pid = content[/pid=(\d+)/, 1]&.to_i
|
|
263
|
+
port = content[/port=(\d+)/, 1]&.to_i || Bridge::DEFAULT_PORT
|
|
264
|
+
|
|
265
|
+
{ pid: pid, port: port }
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Stop the bridge process
|
|
269
|
+
# @return [Boolean] true if stopped
|
|
270
|
+
def stop_bridge
|
|
271
|
+
return false unless bridge_running?
|
|
272
|
+
|
|
273
|
+
info = read_bridge_info
|
|
274
|
+
if info && info[:port]
|
|
275
|
+
require 'net/http'
|
|
276
|
+
begin
|
|
277
|
+
Net::HTTP.start('127.0.0.1', info[:port], open_timeout: 2, read_timeout: 2) do |http|
|
|
278
|
+
http.post('/shutdown', '')
|
|
279
|
+
end
|
|
280
|
+
sleep 0.5
|
|
281
|
+
rescue StandardError
|
|
282
|
+
# HTTP shutdown failed — fall back to SIGTERM
|
|
283
|
+
pid = info[:pid] || File.read(pid_file_path)[/pid=(\d+)/, 1]&.to_i
|
|
284
|
+
Process.kill('TERM', pid) if pid
|
|
285
|
+
sleep 0.5
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
cleanup_stale_files
|
|
290
|
+
true
|
|
291
|
+
rescue Errno::ESRCH
|
|
292
|
+
cleanup_stale_files
|
|
293
|
+
true
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'stream_weaver/canvas/doc_store'
|
|
5
|
+
|
|
6
|
+
module StreamWeaver
|
|
7
|
+
module Canvas
|
|
8
|
+
# Finds every docs root on this machine that canvas-read should show,
|
|
9
|
+
# and gives each one a display label (stream_weaver-iugu).
|
|
10
|
+
#
|
|
11
|
+
# Two sources, unioned, because neither alone is sufficient:
|
|
12
|
+
#
|
|
13
|
+
# - **Scan** of `$STREAMWEAVER_DOCS_SCAN_ROOTS` (default `~/work`), one
|
|
14
|
+
# level deep for `*/docs/streamweaver_canvas`. Catches repos under a
|
|
15
|
+
# known parent automatically -- including ones that arrived via `git
|
|
16
|
+
# pull` and were never saved to or visited on this machine, which is
|
|
17
|
+
# exactly what a registry alone would miss.
|
|
18
|
+
# - **Registry** at `~/.streamweaver/docs_roots.log`: one absolute docs
|
|
19
|
+
# root per line, append-only. Catches repos that live somewhere the
|
|
20
|
+
# scan doesn't reach (`~/rails/...`, `~/src/...`), which is exactly
|
|
21
|
+
# what a scan alone would miss. Written by DocStore.save and by
|
|
22
|
+
# `canvas-read <explicit path>` -- visiting a pre-existing doc once is
|
|
23
|
+
# what backfills it, so there is no separate registration command.
|
|
24
|
+
#
|
|
25
|
+
# Deliberately NOT a database: a flat newline-delimited file of absolute
|
|
26
|
+
# paths needs no schema, no locking (a sub-PIPE_BUF append on a local
|
|
27
|
+
# filesystem is atomic), no corruption recovery, and can be fixed with a
|
|
28
|
+
# text editor. Everything is filtered through File.directory? on read, so
|
|
29
|
+
# a repo that is deleted or moved simply drops off -- self-healing, no
|
|
30
|
+
# `forget` operation to build or remember to call.
|
|
31
|
+
#
|
|
32
|
+
# No cache/TTL on the scan: it is a single-digit-millisecond glob at real
|
|
33
|
+
# repo counts, and staleness in a tool whose whole job is "show me what's
|
|
34
|
+
# on disk right now" is worse than the work it would save.
|
|
35
|
+
module DocRoots
|
|
36
|
+
DEFAULT_REGISTRY_PATH = File.expand_path('~/.streamweaver/docs_roots.log')
|
|
37
|
+
|
|
38
|
+
# Colon-separated, like PATH. `~/work` is the default because that's
|
|
39
|
+
# where the overwhelming majority of repos live; anything else is what
|
|
40
|
+
# the registry half is for.
|
|
41
|
+
DEFAULT_SCAN_ROOTS = '~/work'
|
|
42
|
+
|
|
43
|
+
# The label for DocStore::DEFAULT_ROOT. Not a repo, so it gets a name
|
|
44
|
+
# rather than a basename ('canvas' would be meaningless in the rail).
|
|
45
|
+
GLOBAL_LABEL = 'Global'
|
|
46
|
+
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
# Read fresh on every call (not memoized into a constant) so an ENV
|
|
50
|
+
# override flips behavior mid-process -- same contract DocStore.path
|
|
51
|
+
# has, and what keeps specs from writing to the real registry.
|
|
52
|
+
def registry_path
|
|
53
|
+
env = ENV['STREAMWEAVER_DOCS_REGISTRY']
|
|
54
|
+
env && !env.empty? ? File.expand_path(env) : DEFAULT_REGISTRY_PATH
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# An explicitly empty STREAMWEAVER_DOCS_SCAN_ROOTS means "scan nothing"
|
|
58
|
+
# (registry only), which is distinct from unset meaning "scan ~/work".
|
|
59
|
+
def scan_roots
|
|
60
|
+
raw = ENV['STREAMWEAVER_DOCS_SCAN_ROOTS'] || DEFAULT_SCAN_ROOTS
|
|
61
|
+
raw.split(File::PATH_SEPARATOR).reject { |p| p.strip.empty? }.map { |p| File.expand_path(p.strip) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Symlink-resolved path, used only as a dedupe/comparison KEY -- never
|
|
65
|
+
# for display. macOS's /tmp -> /private/tmp is the everyday case: the
|
|
66
|
+
# same directory reached two ways must not become two sidebar groups.
|
|
67
|
+
# Falls back to expand_path for a path that no longer resolves.
|
|
68
|
+
def canonical(path)
|
|
69
|
+
File.realpath(path)
|
|
70
|
+
rescue SystemCallError
|
|
71
|
+
File.expand_path(path)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Appends `root` to the registry. Returns the recorded absolute path,
|
|
75
|
+
# or nil if there was nothing to record or the write failed.
|
|
76
|
+
#
|
|
77
|
+
# Skips the append when the exact line is already present: duplicates
|
|
78
|
+
# are harmless (reads dedupe), but re-saving the same doc a thousand
|
|
79
|
+
# times shouldn't grow the file a thousand lines. The check is a read
|
|
80
|
+
# of a few hundred bytes, and a lost race just writes a duplicate --
|
|
81
|
+
# which is, again, harmless.
|
|
82
|
+
#
|
|
83
|
+
# Swallows filesystem errors on purpose: failing to record a root
|
|
84
|
+
# degrades discovery, and must never take down the save that triggered
|
|
85
|
+
# it.
|
|
86
|
+
def record(root)
|
|
87
|
+
return nil unless root.is_a?(String) && !root.empty?
|
|
88
|
+
|
|
89
|
+
path = File.expand_path(root)
|
|
90
|
+
return path if raw_entries.include?(path)
|
|
91
|
+
|
|
92
|
+
FileUtils.mkdir_p(File.dirname(registry_path))
|
|
93
|
+
File.open(registry_path, 'a') { |f| f.write("#{path}\n") }
|
|
94
|
+
path
|
|
95
|
+
rescue SystemCallError
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Records `dir` only when nothing already discovers it -- the
|
|
100
|
+
# `canvas-read <explicit path>` backfill. A path the scan already
|
|
101
|
+
# finds needs no registry entry.
|
|
102
|
+
def record_if_new(dir)
|
|
103
|
+
return nil unless dir && File.directory?(dir)
|
|
104
|
+
|
|
105
|
+
key = canonical(dir)
|
|
106
|
+
return nil if roots.any? { |r| canonical(r) == key }
|
|
107
|
+
|
|
108
|
+
record(dir)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Registry contents that still exist, deduped, in file order.
|
|
112
|
+
def registered
|
|
113
|
+
raw_entries.uniq.select { |p| File.directory?(p) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# `*/docs/streamweaver_canvas` under each scan root, one level deep.
|
|
117
|
+
def scanned
|
|
118
|
+
scan_roots.flat_map { |r| Dir.glob(File.join(r, '*', DocStore::DOCS_SUBPATH)) }
|
|
119
|
+
.select { |p| File.directory?(p) }
|
|
120
|
+
.sort
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Every docs root to show, deduped by canonical path and ordered
|
|
124
|
+
# host-repo first, then scanned, then registered, then global.
|
|
125
|
+
#
|
|
126
|
+
# DocStore.path leads deliberately: it is the repo the reader was
|
|
127
|
+
# launched from, it is what the default sidebar filter selects, and it
|
|
128
|
+
# may be reachable by neither other source (a repo outside the scan
|
|
129
|
+
# roots that has been cloned but never saved to on this machine, or a
|
|
130
|
+
# STREAMWEAVER_DOC_ROOT override).
|
|
131
|
+
#
|
|
132
|
+
# Keyed by canonical path, NOT by basename: two repos named `docs` in
|
|
133
|
+
# different parents are two roots, and a basename-keyed hash would
|
|
134
|
+
# silently drop one of them. Basename is a display label only -- see
|
|
135
|
+
# #labels, which disambiguates collisions instead of discarding them.
|
|
136
|
+
#
|
|
137
|
+
# A discovered root that currently holds no `*.{rb,org}` is dropped:
|
|
138
|
+
# File.directory? alone answers "does this place exist," not "is there
|
|
139
|
+
# anything here to show," and a root that once had docs and no longer
|
|
140
|
+
# does (a `git pull` that removed them, deleting the last one from the
|
|
141
|
+
# reader -- stream_weaver-uvaj) is a sidebar heading that discloses
|
|
142
|
+
# nothing. The two roots the host process owns are exempt and always
|
|
143
|
+
# listed while they exist as directories: DocStore.path is the repo the
|
|
144
|
+
# reader was launched from and the target of its own Save-as-doc, and
|
|
145
|
+
# DocStore::DEFAULT_ROOT is the global fallback store -- both need to
|
|
146
|
+
# stay wired up (mtime-watched by the reader's FileList) so the FIRST
|
|
147
|
+
# doc saved into an empty one shows up without a restart.
|
|
148
|
+
def roots
|
|
149
|
+
always = [DocStore.path, DocStore::DEFAULT_ROOT].compact.map { |p| File.expand_path(p) }
|
|
150
|
+
candidates = [DocStore.path, *scanned, *registered, DocStore::DEFAULT_ROOT]
|
|
151
|
+
seen = {}
|
|
152
|
+
candidates.each do |candidate|
|
|
153
|
+
next if candidate.nil? || candidate.to_s.empty?
|
|
154
|
+
|
|
155
|
+
expanded = File.expand_path(candidate)
|
|
156
|
+
next unless File.directory?(expanded)
|
|
157
|
+
next unless always.include?(expanded) || docs?(expanded)
|
|
158
|
+
|
|
159
|
+
seen[canonical(expanded)] ||= expanded
|
|
160
|
+
end
|
|
161
|
+
seen.values
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# True when `root` directly contains at least one doc file. Same
|
|
165
|
+
# '*.{rb,org}' glob FileList.build uses to populate the sidebar, so
|
|
166
|
+
# "root is listed" and "root produces a group" can't disagree.
|
|
167
|
+
def docs?(root)
|
|
168
|
+
Dir.glob(File.join(root, '*.{rb,org}')).any?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# {root => label} for `roots_list`, insertion-ordered. Labels are
|
|
172
|
+
# unique across the hash, since a label is what `?repo=` addresses --
|
|
173
|
+
# two groups sharing one label would be unfilterable.
|
|
174
|
+
def labels(roots_list = roots)
|
|
175
|
+
used = {}
|
|
176
|
+
roots_list.each_with_object({}) do |root, out|
|
|
177
|
+
out[root] = unique_label(root, used)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The repo directory a docs root belongs to -- the root with the
|
|
182
|
+
# `docs/streamweaver_canvas` suffix removed. A root that doesn't carry
|
|
183
|
+
# that suffix (a STREAMWEAVER_DOC_ROOT override, the global store) is
|
|
184
|
+
# its own repo dir.
|
|
185
|
+
def repo_dir(root)
|
|
186
|
+
suffix = File::SEPARATOR + DocStore::DOCS_SUBPATH
|
|
187
|
+
root.end_with?(suffix) ? root[0...-suffix.length] : root
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def base_label(root)
|
|
191
|
+
return GLOBAL_LABEL if canonical(root) == canonical(DocStore::DEFAULT_ROOT)
|
|
192
|
+
|
|
193
|
+
File.basename(repo_dir(root))
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Disambiguates a colliding basename with its parent directory rather
|
|
197
|
+
# than dropping the root -- `~/work/notes` and `~/src/notes` are two
|
|
198
|
+
# real repos and both have to be reachable.
|
|
199
|
+
def unique_label(root, used)
|
|
200
|
+
base = base_label(root)
|
|
201
|
+
return claim(base, used) unless used.key?(base)
|
|
202
|
+
|
|
203
|
+
parent = File.basename(File.dirname(repo_dir(root)))
|
|
204
|
+
candidate = "#{base} (#{parent})"
|
|
205
|
+
n = 2
|
|
206
|
+
while used.key?(candidate)
|
|
207
|
+
candidate = "#{base} (#{parent} #{n})"
|
|
208
|
+
n += 1
|
|
209
|
+
end
|
|
210
|
+
claim(candidate, used)
|
|
211
|
+
end
|
|
212
|
+
private_class_method :unique_label
|
|
213
|
+
|
|
214
|
+
def claim(label, used)
|
|
215
|
+
used[label] = true
|
|
216
|
+
label
|
|
217
|
+
end
|
|
218
|
+
private_class_method :claim
|
|
219
|
+
|
|
220
|
+
# Every non-blank line, expanded, without the directory filter --
|
|
221
|
+
# `record`'s duplicate check needs to see entries that no longer
|
|
222
|
+
# exist, or a deleted-and-recreated root would append forever.
|
|
223
|
+
def raw_entries
|
|
224
|
+
return [] unless File.file?(registry_path)
|
|
225
|
+
|
|
226
|
+
File.readlines(registry_path, chomp: true).map(&:strip).reject(&:empty?).map { |p| File.expand_path(p) }
|
|
227
|
+
rescue SystemCallError
|
|
228
|
+
[]
|
|
229
|
+
end
|
|
230
|
+
private_class_method :raw_entries
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|