charming 0.2.0 → 0.2.1
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 +4 -4
- data/README.md +2 -2
- data/lib/charming/application.rb +96 -9
- data/lib/charming/audio/player.rb +104 -0
- data/lib/charming/audio/system.rb +69 -0
- data/lib/charming/cli.rb +63 -7
- data/lib/charming/controller/action_hooks.rb +124 -0
- data/lib/charming/controller/class_methods.rb +15 -1
- data/lib/charming/controller/dispatching.rb +31 -5
- data/lib/charming/controller/focus.rb +9 -0
- data/lib/charming/controller/focus_management.rb +0 -7
- data/lib/charming/controller/session_state.rb +16 -1
- data/lib/charming/controller/sidebar_navigation.rb +63 -28
- data/lib/charming/controller.rb +62 -10
- data/lib/charming/database/commands.rb +123 -11
- data/lib/charming/events/focus_event.rb +12 -0
- data/lib/charming/events/paste_event.rb +11 -0
- data/lib/charming/events/task_progress_event.rb +21 -0
- data/lib/charming/generators/app_generator.rb +38 -1
- data/lib/charming/generators/database_installer.rb +4 -15
- data/lib/charming/generators/migration_generator.rb +116 -0
- data/lib/charming/generators/migration_timestamp.rb +29 -0
- data/lib/charming/generators/model_generator.rb +4 -2
- data/lib/charming/generators/templates/app/application_controller.template +1 -1
- data/lib/charming/generators/templates/app/database_config.template +3 -1
- data/lib/charming/generators/templates/app/layout.template +1 -1
- data/lib/charming/generators/templates/app/spec_helper.template +2 -1
- data/lib/charming/generators/templates/app/view.template +1 -1
- data/lib/charming/internal/terminal/memory_backend.rb +6 -0
- data/lib/charming/internal/terminal/tty_backend.rb +64 -2
- data/lib/charming/presentation/component.rb +7 -0
- data/lib/charming/presentation/components/audio.rb +31 -0
- data/lib/charming/presentation/components/autocomplete.rb +108 -0
- data/lib/charming/presentation/components/badge.rb +31 -0
- data/lib/charming/presentation/components/breadcrumbs.rb +29 -0
- data/lib/charming/presentation/components/command_palette.rb +8 -5
- data/lib/charming/presentation/components/error_screen.rb +72 -0
- data/lib/charming/presentation/components/form.rb +9 -0
- data/lib/charming/presentation/components/fuzzy_matcher.rb +83 -0
- data/lib/charming/presentation/components/help_overlay.rb +65 -0
- data/lib/charming/presentation/components/markdown.rb +6 -2
- data/lib/charming/presentation/components/modal.rb +45 -5
- data/lib/charming/presentation/components/multi_select_list.rb +85 -0
- data/lib/charming/presentation/components/progressbar.rb +0 -1
- data/lib/charming/presentation/components/status_bar.rb +75 -0
- data/lib/charming/presentation/components/tab_bar.rb +103 -0
- data/lib/charming/presentation/components/table.rb +40 -9
- data/lib/charming/presentation/components/text_area.rb +47 -10
- data/lib/charming/presentation/components/text_input.rb +79 -4
- data/lib/charming/presentation/components/toast.rb +51 -0
- data/lib/charming/presentation/components/tree.rb +176 -0
- data/lib/charming/presentation/components/viewport/content_lines.rb +55 -0
- data/lib/charming/presentation/components/viewport/line_window.rb +71 -0
- data/lib/charming/presentation/components/viewport/position.rb +67 -0
- data/lib/charming/presentation/components/viewport.rb +37 -122
- data/lib/charming/presentation/layout/builder.rb +4 -1
- data/lib/charming/presentation/layout/overlay.rb +6 -4
- data/lib/charming/presentation/layout/pane.rb +2 -1
- data/lib/charming/presentation/layout/pane_geometry.rb +16 -8
- data/lib/charming/presentation/layout/screen_layout.rb +12 -3
- data/lib/charming/presentation/layout/split.rb +37 -3
- data/lib/charming/presentation/markdown/renderer.rb +99 -63
- data/lib/charming/presentation/markdown/style_config.rb +10 -5
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +11 -1
- data/lib/charming/presentation/markdown/table_renderer.rb +60 -0
- data/lib/charming/presentation/markdown/text_wrapper.rb +40 -0
- data/lib/charming/presentation/markdown/url_resolver.rb +27 -0
- data/lib/charming/presentation/templates/erb_handler.rb +35 -2
- data/lib/charming/presentation/ui/ansi_codes.rb +11 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +20 -13
- data/lib/charming/presentation/ui/color_support.rb +129 -0
- data/lib/charming/presentation/ui/theme.rb +7 -0
- data/lib/charming/presentation/ui/themes/catppuccin-latte.json +35 -0
- data/lib/charming/presentation/ui/themes/catppuccin-mocha.json +35 -0
- data/lib/charming/presentation/ui/themes/gruvbox-dark.json +33 -0
- data/lib/charming/presentation/ui/themes/nord.json +32 -0
- data/lib/charming/presentation/ui/themes/tokyonight.json +34 -0
- data/lib/charming/presentation/ui/width.rb +27 -2
- data/lib/charming/router.rb +1 -1
- data/lib/charming/runtime.rb +122 -15
- data/lib/charming/tasks/cancelled.rb +11 -0
- data/lib/charming/tasks/inline_executor.rb +10 -4
- data/lib/charming/tasks/progress.rb +30 -0
- data/lib/charming/tasks/task.rb +24 -4
- data/lib/charming/tasks/threaded_executor.rb +35 -11
- data/lib/charming/test_helper.rb +120 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +43 -1
- metadata +36 -49
|
@@ -83,6 +83,15 @@ module Charming
|
|
|
83
83
|
current == slot
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
# True when the topmost scope is an overlay (modal, command palette, or any
|
|
87
|
+
# pushed scope) rather than the structural ring/layout. While an overlay scope
|
|
88
|
+
# is active, the controller routes keys to the focused component instead of
|
|
89
|
+
# content/sidebar key bindings.
|
|
90
|
+
def overlay?
|
|
91
|
+
scope = top
|
|
92
|
+
!!scope && !%i[ring layout].include?(scope[:origin])
|
|
93
|
+
end
|
|
94
|
+
|
|
86
95
|
private
|
|
87
96
|
|
|
88
97
|
# Returns the topmost scope hash (the last entry pushed onto `@state[:scopes]`).
|
|
@@ -18,13 +18,6 @@ module Charming
|
|
|
18
18
|
def focused?(slot)
|
|
19
19
|
focus.focused?(slot)
|
|
20
20
|
end
|
|
21
|
-
|
|
22
|
-
private
|
|
23
|
-
|
|
24
|
-
# True when the controller class declared *slot* as part of its focus_ring DSL.
|
|
25
|
-
def focus_ring_slot?(slot)
|
|
26
|
-
self.class.focus_ring_slots.include?(slot)
|
|
27
|
-
end
|
|
28
21
|
end
|
|
29
22
|
end
|
|
30
23
|
end
|
|
@@ -44,9 +44,24 @@ module Charming
|
|
|
44
44
|
# Submits a background task with the given *name*. The block is executed by the configured
|
|
45
45
|
# task executor; its return value (or any raised exception) is delivered to the controller
|
|
46
46
|
# as a TaskEvent dispatched to the matching `on_task` handler.
|
|
47
|
-
|
|
47
|
+
#
|
|
48
|
+
# Blocks that accept an argument receive a Tasks::Progress reporter whose `report`
|
|
49
|
+
# calls dispatch the matching `on_task_progress` handler. *timeout:* (seconds)
|
|
50
|
+
# cancels the task with Tasks::Cancelled when exceeded.
|
|
51
|
+
def run_task(name, timeout: nil, &block)
|
|
52
|
+
return application.task_executor.submit(name, timeout: timeout, &block) if timeout
|
|
53
|
+
|
|
54
|
+
# Without a timeout, use the plain signature so simple custom executors
|
|
55
|
+
# (`def submit(name, &block)`) remain compatible.
|
|
48
56
|
application.task_executor.submit(name, &block)
|
|
49
57
|
end
|
|
58
|
+
|
|
59
|
+
# Cancels the named in-flight background task (raises Tasks::Cancelled inside it).
|
|
60
|
+
# No-op when the task already finished or the executor doesn't support cancellation.
|
|
61
|
+
def cancel_task(name)
|
|
62
|
+
executor = application.task_executor
|
|
63
|
+
executor.cancel(name) if executor.respond_to?(:cancel)
|
|
64
|
+
end
|
|
50
65
|
end
|
|
51
66
|
end
|
|
52
67
|
end
|
|
@@ -4,41 +4,36 @@ module Charming
|
|
|
4
4
|
class Controller
|
|
5
5
|
# Sidebar-navigation helpers mixed into Controller. Tracks the sidebar's current route index,
|
|
6
6
|
# routes j/k/enter/tab keys when the sidebar is focused, and exposes `sidebar_focused?` for views.
|
|
7
|
+
#
|
|
8
|
+
# Sidebar/content focus is driven entirely by the controller's Focus object. Controllers
|
|
9
|
+
# that want Tab-driven sidebar navigation declare `focus_ring :sidebar, :content` (generated
|
|
10
|
+
# apps do); without those slots in the ring, `focus_sidebar`/`focus_content` are no-ops.
|
|
7
11
|
module SidebarNavigation
|
|
8
|
-
# Moves focus to the sidebar
|
|
9
|
-
# is updated; otherwise a fallback session key tracks focus.
|
|
12
|
+
# Moves focus to the sidebar slot and remembers the highlighted route.
|
|
10
13
|
def focus_sidebar
|
|
11
|
-
|
|
12
|
-
focus.focus(:sidebar)
|
|
13
|
-
else
|
|
14
|
-
session[:focus] = :sidebar
|
|
15
|
-
end
|
|
14
|
+
focus.focus(:sidebar)
|
|
16
15
|
session[:sidebar_index] ||= current_route_index
|
|
17
16
|
render_default_action
|
|
18
17
|
end
|
|
19
18
|
|
|
20
|
-
# Moves focus to the content
|
|
19
|
+
# Moves focus to the content side (the inverse of `focus_sidebar`). "Content" is
|
|
20
|
+
# the :content slot when the ring declares one, otherwise the first non-sidebar
|
|
21
|
+
# slot — so `focus_ring :sidebar, :entries` works without a literal :content.
|
|
21
22
|
def focus_content
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
else
|
|
25
|
-
session[:focus] = :content
|
|
26
|
-
end
|
|
23
|
+
slot = content_slot
|
|
24
|
+
focus.focus(slot) if slot
|
|
27
25
|
render_default_action
|
|
28
26
|
end
|
|
29
27
|
|
|
30
|
-
# True when the sidebar is the current focus target.
|
|
28
|
+
# True when the sidebar slot is the current focus target.
|
|
31
29
|
def sidebar_focused?
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
session[:focus] == :sidebar
|
|
30
|
+
focused?(:sidebar)
|
|
35
31
|
end
|
|
36
32
|
|
|
37
|
-
# True when
|
|
33
|
+
# True when focus is on the content side: any current slot other than :sidebar.
|
|
38
34
|
def content_focused?
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
session[:focus] == :content
|
|
35
|
+
current = focus.current
|
|
36
|
+
!current.nil? && current != :sidebar
|
|
42
37
|
end
|
|
43
38
|
|
|
44
39
|
# Returns the index of the currently selected route in `sidebar_routes`, defaulting to the
|
|
@@ -82,9 +77,43 @@ module Charming
|
|
|
82
77
|
response
|
|
83
78
|
end
|
|
84
79
|
|
|
85
|
-
# Mouse dispatch for the sidebar
|
|
80
|
+
# Mouse dispatch for the sidebar: a click on a route row selects it and navigates
|
|
81
|
+
# immediately; a click elsewhere in the sidebar pane focuses the sidebar. Uses the
|
|
82
|
+
# :sidebar pane's inner rect from the latest render to translate screen coordinates
|
|
83
|
+
# to nav rows. Returns nil when the click missed the sidebar entirely.
|
|
86
84
|
def dispatch_sidebar_mouse
|
|
87
|
-
nil
|
|
85
|
+
return nil unless event.respond_to?(:click?) && event.click?
|
|
86
|
+
|
|
87
|
+
row = sidebar_row_at(event.x, event.y)
|
|
88
|
+
return nil unless row
|
|
89
|
+
|
|
90
|
+
if row.between?(0, sidebar_routes.length - 1)
|
|
91
|
+
session[:sidebar_index] = row
|
|
92
|
+
sidebar_select
|
|
93
|
+
else
|
|
94
|
+
focus.focus(:sidebar)
|
|
95
|
+
render_default_action
|
|
96
|
+
end
|
|
97
|
+
response
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The number of rows above the first nav item inside the sidebar pane's content
|
|
101
|
+
# area (the generated layout renders the app title plus a blank gap line).
|
|
102
|
+
# Override in controllers whose sidebar layout differs.
|
|
103
|
+
def sidebar_nav_offset
|
|
104
|
+
2
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Maps screen coordinates to a nav-row index inside the :sidebar pane's inner
|
|
108
|
+
# rect, or nil when the click missed the sidebar.
|
|
109
|
+
def sidebar_row_at(x, y)
|
|
110
|
+
target = mouse_targets.find { |candidate| candidate[:name] == :sidebar }
|
|
111
|
+
return nil unless target
|
|
112
|
+
|
|
113
|
+
inner = target.fetch(:inner_rect)
|
|
114
|
+
return nil unless inner.cover?(x, y)
|
|
115
|
+
|
|
116
|
+
y - inner.y - sidebar_nav_offset
|
|
88
117
|
end
|
|
89
118
|
|
|
90
119
|
# Moves the sidebar cursor by *delta* positions, clamped to the route list bounds.
|
|
@@ -96,14 +125,20 @@ module Charming
|
|
|
96
125
|
render_default_action
|
|
97
126
|
end
|
|
98
127
|
|
|
128
|
+
# The slot focus_content targets: :content when declared, else the first
|
|
129
|
+
# non-sidebar slot in the active ring.
|
|
130
|
+
def content_slot
|
|
131
|
+
ring = focus.ring
|
|
132
|
+
return :content if ring.include?(:content)
|
|
133
|
+
|
|
134
|
+
ring.find { |slot| slot != :sidebar }
|
|
135
|
+
end
|
|
136
|
+
|
|
99
137
|
# Selects the route currently highlighted in the sidebar and navigates to it.
|
|
100
138
|
def sidebar_select
|
|
101
139
|
route = sidebar_routes[sidebar_index]
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
else
|
|
105
|
-
session[:focus] = :content
|
|
106
|
-
end
|
|
140
|
+
slot = content_slot
|
|
141
|
+
focus.focus(slot) if slot
|
|
107
142
|
route ? navigate_to(route.path) : render_default_action
|
|
108
143
|
end
|
|
109
144
|
end
|
data/lib/charming/controller.rb
CHANGED
|
@@ -9,6 +9,7 @@ module Charming
|
|
|
9
9
|
TaskBinding = Data.define(:name, :action)
|
|
10
10
|
|
|
11
11
|
extend ClassMethods
|
|
12
|
+
include ActionHooks
|
|
12
13
|
include Rendering
|
|
13
14
|
include SessionState
|
|
14
15
|
include FocusManagement
|
|
@@ -30,22 +31,50 @@ module Charming
|
|
|
30
31
|
@response = nil
|
|
31
32
|
end
|
|
32
33
|
|
|
33
|
-
# Dispatches a named action on this controller (e.g. :show)
|
|
34
|
+
# Dispatches a named action on this controller (e.g. :show), running all
|
|
35
|
+
# before/around/after hooks and rescue_from handlers.
|
|
34
36
|
def dispatch(action)
|
|
35
|
-
|
|
37
|
+
run_action_with_hooks(action)
|
|
36
38
|
render_default_action if response.nil? && auto_render_after?(action)
|
|
37
39
|
response || render("")
|
|
38
40
|
end
|
|
39
41
|
|
|
40
|
-
# Key event dispatch
|
|
41
|
-
#
|
|
42
|
+
# Key event dispatch, in priority order:
|
|
43
|
+
# 1. Command palette (when open) consumes everything.
|
|
44
|
+
# 2. A focused text-capturing component (TextInput, TextArea, Form, …) gets
|
|
45
|
+
# printable characters BEFORE key bindings — typing "q" into a field must
|
|
46
|
+
# insert a q, not quit the app.
|
|
47
|
+
# 3. Global key bindings.
|
|
48
|
+
# 4. An overlay focus scope (a pushed modal) captures all remaining keys.
|
|
49
|
+
# 5. Sidebar keys (when focused), content bindings, then the focused component —
|
|
50
|
+
# which sees Tab before ring traversal so forms can do field navigation.
|
|
42
51
|
def dispatch_key
|
|
43
52
|
return dispatch_command_palette_key if command_palette_open?
|
|
53
|
+
|
|
54
|
+
if printable_text_event? && focused_component_captures_text?
|
|
55
|
+
return response if dispatch_to_focused_component == :handled
|
|
56
|
+
end
|
|
57
|
+
|
|
44
58
|
return dispatch(global_key_action) if global_key_action
|
|
59
|
+
|
|
60
|
+
if focus.overlay?
|
|
61
|
+
dispatch_to_focused_component
|
|
62
|
+
return response
|
|
63
|
+
end
|
|
64
|
+
|
|
45
65
|
return dispatch_sidebar_key if sidebar_focused?
|
|
46
66
|
return dispatch(content_key_action) if content_key_action
|
|
47
|
-
|
|
48
|
-
|
|
67
|
+
|
|
68
|
+
# Text-capturing components (forms, editors) own their remaining keys — Tab
|
|
69
|
+
# included, so forms do field navigation. Everything else keeps ring traversal
|
|
70
|
+
# ahead of the component.
|
|
71
|
+
if focused_component_captures_text?
|
|
72
|
+
return response if dispatch_to_focused_component == :handled
|
|
73
|
+
return response if dispatch_tab_traversal == :handled
|
|
74
|
+
else
|
|
75
|
+
return response if dispatch_tab_traversal == :handled
|
|
76
|
+
return response if dispatch_to_focused_component == :handled
|
|
77
|
+
end
|
|
49
78
|
nil
|
|
50
79
|
end
|
|
51
80
|
|
|
@@ -64,14 +93,37 @@ module Charming
|
|
|
64
93
|
b ? dispatch(b.action) : nil
|
|
65
94
|
end
|
|
66
95
|
|
|
67
|
-
#
|
|
96
|
+
# Task progress dispatcher: looks up the handler in task progress bindings.
|
|
97
|
+
def dispatch_task_progress
|
|
98
|
+
b = self.class.task_progress_bindings[event.name.to_sym]
|
|
99
|
+
b ? dispatch(b.action) : nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Paste event dispatcher: forwards pasted text to the focused component's
|
|
103
|
+
# `handle_paste` (TextInput, TextArea, and form fields support it).
|
|
104
|
+
def dispatch_paste
|
|
105
|
+
slot = focus.current
|
|
106
|
+
return nil unless slot && respond_to?(slot, true)
|
|
107
|
+
|
|
108
|
+
component = send(slot)
|
|
109
|
+
return nil unless component.respond_to?(:handle_paste)
|
|
110
|
+
|
|
111
|
+
result = component.handle_paste(event)
|
|
112
|
+
return nil if result.nil?
|
|
113
|
+
|
|
114
|
+
dispatch_component_result(slot, result)
|
|
115
|
+
response
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Mouse event dispatcher: command palette (if open) wins, then sidebar clicks
|
|
119
|
+
# (route rows navigate directly), then named layout panes/components.
|
|
68
120
|
def dispatch_mouse
|
|
69
121
|
return dispatch_command_palette_mouse if command_palette_open?
|
|
70
122
|
|
|
71
|
-
|
|
72
|
-
return
|
|
123
|
+
sidebar_response = dispatch_sidebar_mouse
|
|
124
|
+
return sidebar_response if sidebar_response
|
|
73
125
|
|
|
74
|
-
|
|
126
|
+
dispatch_component_mouse
|
|
75
127
|
end
|
|
76
128
|
|
|
77
129
|
# Renders a body or template wrapped in the controller's layout.
|
|
@@ -8,11 +8,17 @@ module Charming
|
|
|
8
8
|
# `db:install`, which lives in Generators::DatabaseInstaller). It loads the app's
|
|
9
9
|
# `config/database.rb`, delegates the actual work to ActiveRecord, and prints a short
|
|
10
10
|
# status line on success.
|
|
11
|
+
#
|
|
12
|
+
# Supported commands: db:create, db:migrate, db:rollback [STEP=n], db:drop, db:seed,
|
|
13
|
+
# db:setup, db:reset, db:prepare, db:status, db:version, db:schema:dump, db:schema:load.
|
|
14
|
+
# The target database file is selected by CHARMING_ENV (development, test, production).
|
|
11
15
|
class Commands
|
|
12
|
-
# *command* is the subcommand string (e.g., "db:create"). *
|
|
13
|
-
#
|
|
14
|
-
|
|
16
|
+
# *command* is the subcommand string (e.g., "db:create"). *args* holds extra CLI
|
|
17
|
+
# arguments such as "STEP=2". *out* is the status-output stream. *destination* is the
|
|
18
|
+
# app root for resolving `config/database.rb` and `db/`.
|
|
19
|
+
def initialize(command, out:, destination:, args: [])
|
|
15
20
|
@command = command
|
|
21
|
+
@args = args
|
|
16
22
|
@out = out
|
|
17
23
|
@destination = destination
|
|
18
24
|
end
|
|
@@ -25,14 +31,21 @@ module Charming
|
|
|
25
31
|
when "db:rollback" then rollback
|
|
26
32
|
when "db:drop" then drop
|
|
27
33
|
when "db:seed" then seed
|
|
34
|
+
when "db:setup" then setup
|
|
35
|
+
when "db:reset" then reset
|
|
36
|
+
when "db:prepare" then prepare
|
|
37
|
+
when "db:status" then status
|
|
38
|
+
when "db:version" then version
|
|
39
|
+
when "db:schema:dump" then schema_dump
|
|
40
|
+
when "db:schema:load" then schema_load
|
|
28
41
|
else raise Generators::Error, "Unknown database command: #{command}"
|
|
29
42
|
end
|
|
30
43
|
end
|
|
31
44
|
|
|
32
45
|
private
|
|
33
46
|
|
|
34
|
-
# The subcommand, output stream, and app destination.
|
|
35
|
-
attr_reader :command, :out, :destination
|
|
47
|
+
# The subcommand, extra arguments, output stream, and app destination.
|
|
48
|
+
attr_reader :command, :args, :out, :destination
|
|
36
49
|
|
|
37
50
|
# Creates the SQLite database file (touch) and establishes the connection.
|
|
38
51
|
def create
|
|
@@ -43,18 +56,21 @@ module Charming
|
|
|
43
56
|
out.puts "create #{relative_database_path}"
|
|
44
57
|
end
|
|
45
58
|
|
|
46
|
-
# Runs all pending migrations from `db/migrate`.
|
|
59
|
+
# Runs all pending migrations from `db/migrate`, then refreshes `db/schema.rb`.
|
|
47
60
|
def migrate
|
|
48
61
|
load_database
|
|
49
62
|
migration_context.migrate
|
|
63
|
+
dump_schema
|
|
50
64
|
out.puts "migrate db/migrate"
|
|
51
65
|
end
|
|
52
66
|
|
|
53
|
-
# Rolls back the most recent migration.
|
|
67
|
+
# Rolls back the most recent migration(s). Accepts `STEP=n` (default 1), then
|
|
68
|
+
# refreshes `db/schema.rb`.
|
|
54
69
|
def rollback
|
|
55
70
|
load_database
|
|
56
|
-
migration_context.rollback(
|
|
57
|
-
|
|
71
|
+
migration_context.rollback(step_argument)
|
|
72
|
+
dump_schema
|
|
73
|
+
out.puts "rollback db/migrate (#{step_argument} step#{"s" if step_argument > 1})"
|
|
58
74
|
end
|
|
59
75
|
|
|
60
76
|
# Disconnects ActiveRecord, then deletes the database file.
|
|
@@ -65,16 +81,92 @@ module Charming
|
|
|
65
81
|
out.puts "drop #{relative_database_path}"
|
|
66
82
|
end
|
|
67
83
|
|
|
68
|
-
# Loads `db/seeds.rb` (raises if missing).
|
|
84
|
+
# Loads `db/seeds.rb` (raises if missing). The full application is loaded first so
|
|
85
|
+
# seeds can reference app models.
|
|
69
86
|
def seed
|
|
70
87
|
load_database
|
|
71
|
-
|
|
88
|
+
load_application
|
|
72
89
|
raise Generators::Error, "Missing file: db/seeds.rb" unless File.exist?(seed_path)
|
|
73
90
|
|
|
74
91
|
load seed_path
|
|
75
92
|
out.puts "seed db/seeds.rb"
|
|
76
93
|
end
|
|
77
94
|
|
|
95
|
+
# Creates the database, loads the schema when one exists (otherwise migrates), and seeds.
|
|
96
|
+
def setup
|
|
97
|
+
create
|
|
98
|
+
File.exist?(schema_path) ? schema_load : migrate
|
|
99
|
+
seed if File.exist?(seed_path)
|
|
100
|
+
out.puts "setup #{relative_database_path}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Drops and re-creates the database from scratch (drop + setup).
|
|
104
|
+
def reset
|
|
105
|
+
load_database
|
|
106
|
+
drop
|
|
107
|
+
setup
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# CI-friendly: sets up the database when it doesn't exist, otherwise just migrates.
|
|
111
|
+
def prepare
|
|
112
|
+
load_database
|
|
113
|
+
if database_path && File.exist?(database_path)
|
|
114
|
+
migrate
|
|
115
|
+
else
|
|
116
|
+
setup
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Prints a Rails-style migration status table (status, version, name).
|
|
121
|
+
def status
|
|
122
|
+
load_database
|
|
123
|
+
out.puts "Status Version Name"
|
|
124
|
+
out.puts "-" * 48
|
|
125
|
+
migration_context.migrations_status.each do |migration_status, version, name|
|
|
126
|
+
out.puts format("%-8s %-16s %s", migration_status, version, name)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Prints the current schema version.
|
|
131
|
+
def version
|
|
132
|
+
load_database
|
|
133
|
+
out.puts "version #{migration_context.current_version}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Writes the current database structure to `db/schema.rb`.
|
|
137
|
+
def schema_dump
|
|
138
|
+
load_database
|
|
139
|
+
dump_schema
|
|
140
|
+
out.puts "dump db/schema.rb"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Loads `db/schema.rb` into the database (fast alternative to replaying migrations).
|
|
144
|
+
def schema_load
|
|
145
|
+
load_database
|
|
146
|
+
raise Generators::Error, "Missing file: db/schema.rb. Run `charming db:schema:dump` first." unless File.exist?(schema_path)
|
|
147
|
+
|
|
148
|
+
load schema_path
|
|
149
|
+
out.puts "load db/schema.rb"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Dumps the connected database's structure to `db/schema.rb` via ActiveRecord.
|
|
153
|
+
def dump_schema
|
|
154
|
+
File.open(schema_path, "w") do |file|
|
|
155
|
+
ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection_pool, file)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Parses the `STEP=n` argument for db:rollback (defaults to 1).
|
|
160
|
+
def step_argument
|
|
161
|
+
step = args.find { |arg| arg.start_with?("STEP=") }
|
|
162
|
+
return 1 unless step
|
|
163
|
+
|
|
164
|
+
value = step.delete_prefix("STEP=").to_i
|
|
165
|
+
raise Generators::Error, "STEP must be a positive integer" unless value.positive?
|
|
166
|
+
|
|
167
|
+
value
|
|
168
|
+
end
|
|
169
|
+
|
|
78
170
|
# Loads the app's `config/database.rb` (raises if missing) which establishes the connection.
|
|
79
171
|
def load_database
|
|
80
172
|
database_config = File.join(destination, "config", "database.rb")
|
|
@@ -83,11 +175,31 @@ module Charming
|
|
|
83
175
|
require database_config
|
|
84
176
|
end
|
|
85
177
|
|
|
178
|
+
# Requires the app's root loader (`lib/<app>.rb`, derived from the gemspec name) so
|
|
179
|
+
# app constants — models in particular — are available. No-op when not in an app root.
|
|
180
|
+
def load_application
|
|
181
|
+
gemspec = Dir.glob(File.join(destination, "*.gemspec")).first
|
|
182
|
+
return unless gemspec
|
|
183
|
+
|
|
184
|
+
root_file = File.join(destination, "lib", "#{File.basename(gemspec, ".gemspec")}.rb")
|
|
185
|
+
require root_file if File.exist?(root_file)
|
|
186
|
+
end
|
|
187
|
+
|
|
86
188
|
# The ActiveRecord migration context rooted at `db/migrate` inside the app.
|
|
87
189
|
def migration_context
|
|
88
190
|
ActiveRecord::MigrationContext.new(File.join(destination, "db", "migrate"))
|
|
89
191
|
end
|
|
90
192
|
|
|
193
|
+
# Path to the app's `db/schema.rb`.
|
|
194
|
+
def schema_path
|
|
195
|
+
File.join(destination, "db", "schema.rb")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Path to the app's `db/seeds.rb`.
|
|
199
|
+
def seed_path
|
|
200
|
+
File.join(destination, "db", "seeds.rb")
|
|
201
|
+
end
|
|
202
|
+
|
|
91
203
|
# The configured database file path (nil when ActiveRecord isn't connected to a file).
|
|
92
204
|
def database_path
|
|
93
205
|
ActiveRecord::Base.connection_db_config.database
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Events
|
|
5
|
+
# FocusEvent reports the terminal window gaining or losing focus (focus reporting
|
|
6
|
+
# mode `\e[?1004h`, markers `\e[I` / `\e[O`). Controllers opt in by defining a
|
|
7
|
+
# `focus_changed` action; apps can use it to pause timers or dim the UI.
|
|
8
|
+
FocusEvent = Data.define(:focused) do
|
|
9
|
+
def focused? = focused
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Events
|
|
5
|
+
# PasteEvent carries text pasted via the terminal's bracketed-paste mode
|
|
6
|
+
# (`\e[200~ ... \e[201~`). Without bracketed paste, pasted text arrives as a storm
|
|
7
|
+
# of individual key events; with it, components receive the whole string at once
|
|
8
|
+
# via `handle_paste`.
|
|
9
|
+
PasteEvent = Data.define(:text)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Events
|
|
5
|
+
# TaskProgressEvent reports incremental progress from a running background task.
|
|
6
|
+
# *name* matches the task name, *current*/*total* describe completion (total may be
|
|
7
|
+
# nil for indeterminate work), and *message* is an optional human-readable status.
|
|
8
|
+
TaskProgressEvent = Data.define(:name, :current, :total, :message) do
|
|
9
|
+
def initialize(name:, current:, total: nil, message: nil)
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Completion as a 0.0..1.0 fraction, or nil when the total is unknown.
|
|
14
|
+
def fraction
|
|
15
|
+
return nil unless total&.positive?
|
|
16
|
+
|
|
17
|
+
(current.to_f / total).clamp(0.0, 1.0)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -98,10 +98,47 @@ module Charming
|
|
|
98
98
|
controller_actions: controller_actions,
|
|
99
99
|
controller_helpers: controller_helpers,
|
|
100
100
|
database_require: database_require,
|
|
101
|
-
model_loader: model_loader
|
|
101
|
+
model_loader: model_loader,
|
|
102
|
+
env_setup: env_setup,
|
|
103
|
+
database_spec_setup: database_spec_setup
|
|
102
104
|
}
|
|
103
105
|
end
|
|
104
106
|
|
|
107
|
+
# Pins CHARMING_ENV to "test" before the app (and its database config) loads, so
|
|
108
|
+
# specs hit db/test.sqlite3. Empty for non-database apps.
|
|
109
|
+
def env_setup
|
|
110
|
+
return "" unless database?
|
|
111
|
+
|
|
112
|
+
"ENV[\"CHARMING_ENV\"] ||= \"test\"\n\n"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Prepares the test database schema before the suite and rolls back each example's
|
|
116
|
+
# writes in a transaction. Empty for non-database apps.
|
|
117
|
+
def database_spec_setup
|
|
118
|
+
return "" unless database?
|
|
119
|
+
|
|
120
|
+
<<~RUBY
|
|
121
|
+
|
|
122
|
+
# Prepare the test database, preferring the dumped schema over replaying migrations.
|
|
123
|
+
schema = File.expand_path("../db/schema.rb", __dir__)
|
|
124
|
+
if File.exist?(schema)
|
|
125
|
+
load schema
|
|
126
|
+
else
|
|
127
|
+
ActiveRecord::MigrationContext.new(File.expand_path("../db/migrate", __dir__)).migrate
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
RSpec.configure do |config|
|
|
131
|
+
# Roll back database writes after each example so tests stay isolated.
|
|
132
|
+
config.around(:each) do |example|
|
|
133
|
+
ActiveRecord::Base.transaction(requires_new: true) do
|
|
134
|
+
example.run
|
|
135
|
+
raise ActiveRecord::Rollback
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
RUBY
|
|
140
|
+
end
|
|
141
|
+
|
|
105
142
|
# The `Gem::Specification` attributes block (indented two spaces to match the wrapping
|
|
106
143
|
# `Gem::Specification.new do |spec|`).
|
|
107
144
|
def gemspec_attributes
|
|
@@ -100,22 +100,11 @@ module Charming
|
|
|
100
100
|
content.sub(%( spec.add_dependency "charming"\n), %( spec.add_dependency "charming"\n#{dependency}\n))
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
-
# The contents of the new `config/database.rb`
|
|
104
|
-
# `db
|
|
103
|
+
# The contents of the new `config/database.rb` — read from the shared app template so
|
|
104
|
+
# `charming new --database` and `charming db:install` stay in sync. Environment-aware:
|
|
105
|
+
# the database file is `db/<CHARMING_ENV>.sqlite3`.
|
|
105
106
|
def database_config
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
require "active_record"
|
|
109
|
-
require "fileutils"
|
|
110
|
-
|
|
111
|
-
database_path = File.expand_path("../db/development.sqlite3", __dir__)
|
|
112
|
-
FileUtils.mkdir_p(File.dirname(database_path))
|
|
113
|
-
|
|
114
|
-
ActiveRecord::Base.establish_connection(
|
|
115
|
-
adapter: "sqlite3",
|
|
116
|
-
database: database_path
|
|
117
|
-
)
|
|
118
|
-
)
|
|
107
|
+
File.read(File.join(__dir__, "templates", "app", "database_config.template"))
|
|
119
108
|
end
|
|
120
109
|
|
|
121
110
|
# The contents of the new `app/models/application_record.rb` (abstract ActiveRecord base).
|