charming 0.1.3 → 0.1.4
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/lib/charming/application.rb +19 -2
- data/lib/charming/cli.rb +3 -3
- data/lib/charming/controller/component_dispatching.rb +47 -3
- data/lib/charming/controller/focus.rb +123 -0
- data/lib/charming/controller/focus_management.rb +1 -1
- data/lib/charming/controller/rendering.rb +4 -15
- data/lib/charming/controller/session_state.rb +11 -0
- data/lib/charming/controller.rb +11 -2
- data/lib/charming/database/commands.rb +106 -0
- data/lib/charming/generators/database_installer.rb +154 -0
- data/lib/charming/generators/model_generator.rb +2 -10
- data/lib/charming/generators/name.rb +1 -1
- data/lib/charming/generators/view_generator.rb +1 -1
- data/lib/charming/presentation/components/form/field.rb +1 -1
- data/lib/charming/presentation/components/markdown.rb +7 -7
- data/lib/charming/presentation/layout/pane.rb +7 -0
- data/lib/charming/presentation/layout/rect.rb +5 -0
- data/lib/charming/presentation/layout/screen_layout.rb +7 -0
- data/lib/charming/presentation/layout/split.rb +7 -0
- data/lib/charming/presentation/markdown/render_context.rb +28 -10
- data/lib/charming/presentation/markdown/renderer.rb +264 -39
- data/lib/charming/presentation/markdown/style_config.rb +215 -0
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +3 -2
- data/lib/charming/presentation/markdown.rb +2 -2
- data/lib/charming/presentation/view.rb +7 -0
- data/lib/charming/router.rb +3 -8
- data/lib/charming/runtime.rb +2 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +2 -2
- metadata +42 -9
- data/lib/charming/database_commands.rb +0 -103
- data/lib/charming/database_installer.rb +0 -152
- data/lib/charming/focus.rb +0 -121
- data/lib/charming/presentation/markdown/block_renderers.rb +0 -118
- data/lib/charming/presentation/markdown/inline_renderers.rb +0 -66
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 63a0e6137fddcdcd90ebeb7f64a4a5d8b7d24bce082a87f27273eadc3d20a3e7
|
|
4
|
+
data.tar.gz: 925a673a508c582bb259da12794653b65ac30d6184e9b48f586db0ae3d0de7ba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 18cc22164930e56efe5c768fd947c7dd23009b49d2266b5d849b71dedae30202a4b32540a9c595ec5490ae4b5ee7a6f0bf167796fcbd5fb41e674db312696840
|
|
7
|
+
data.tar.gz: 4b2ed08745f24cc99082d604247e1cf1e501f2188f217ed70ced49065c22587e59c335ce11aede01a47ba2626de78b9cccb2fafa50abea8273b86eda779f0736
|
data/lib/charming/application.rb
CHANGED
|
@@ -5,6 +5,7 @@ module Charming
|
|
|
5
5
|
# terminal-based apps. It provides routing (via a DSL), session storage, and
|
|
6
6
|
# task execution for managing async operations.
|
|
7
7
|
class Application
|
|
8
|
+
LOGGER_READER = Object.new.freeze
|
|
8
9
|
THEME_READER = Object.new.freeze
|
|
9
10
|
|
|
10
11
|
class << self
|
|
@@ -19,7 +20,15 @@ module Charming
|
|
|
19
20
|
# Derives the module namespace from the class name — e.g., Admin::HomeController
|
|
20
21
|
# yields "Admin". Mirrors Rails' engine-style namespacing.
|
|
21
22
|
def namespace
|
|
22
|
-
|
|
23
|
+
ActiveSupport::Inflector.deconstantize(name.to_s)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns or sets the app logger. Defaults to a null-device logger so app and framework code
|
|
27
|
+
# can safely call logging methods without writing into the terminal UI.
|
|
28
|
+
def logger(value = LOGGER_READER)
|
|
29
|
+
return configured_logger if value == LOGGER_READER
|
|
30
|
+
|
|
31
|
+
@logger = value
|
|
23
32
|
end
|
|
24
33
|
|
|
25
34
|
# Returns the app's filesystem root, used to resolve relative theme and template paths.
|
|
@@ -67,6 +76,13 @@ module Charming
|
|
|
67
76
|
|
|
68
77
|
private
|
|
69
78
|
|
|
79
|
+
def configured_logger
|
|
80
|
+
return @logger if instance_variable_defined?(:@logger)
|
|
81
|
+
return superclass.logger if superclass.respond_to?(:logger)
|
|
82
|
+
|
|
83
|
+
@logger = Logger.new(File::NULL)
|
|
84
|
+
end
|
|
85
|
+
|
|
70
86
|
# Expands a relative theme path against the app root (or the current working directory
|
|
71
87
|
# when no root is configured). Returns *path* unchanged when it is already absolute.
|
|
72
88
|
def resolve_theme_path(path)
|
|
@@ -76,11 +92,12 @@ module Charming
|
|
|
76
92
|
end
|
|
77
93
|
end
|
|
78
94
|
|
|
79
|
-
attr_accessor :task_executor
|
|
95
|
+
attr_accessor :logger, :task_executor
|
|
80
96
|
attr_reader :session
|
|
81
97
|
|
|
82
98
|
# Initializes an empty session hash for per-request state storage.
|
|
83
99
|
def initialize
|
|
100
|
+
@logger = self.class.logger
|
|
84
101
|
@session = {}
|
|
85
102
|
end
|
|
86
103
|
|
data/lib/charming/cli.rb
CHANGED
|
@@ -75,17 +75,17 @@ module Charming
|
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
# Routes `db:*` commands to either the install path (db:install) or the generic
|
|
78
|
-
#
|
|
78
|
+
# Database::Commands dispatcher.
|
|
79
79
|
def database(command, args)
|
|
80
80
|
if command == "db:install"
|
|
81
81
|
database = args.shift || raise(Generators::Error, "Usage: charming db:install sqlite3")
|
|
82
82
|
raise Generators::Error, "Usage: charming db:install sqlite3" if args.any?
|
|
83
83
|
|
|
84
|
-
DatabaseInstaller.new(database, out: out, destination: pwd).install
|
|
84
|
+
Generators::DatabaseInstaller.new(database, out: out, destination: pwd).install
|
|
85
85
|
else
|
|
86
86
|
raise Generators::Error, "Usage: charming #{command}" if args.any?
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
Database::Commands.new(command, out: out, destination: pwd).run
|
|
89
89
|
end
|
|
90
90
|
0
|
|
91
91
|
end
|
|
@@ -71,10 +71,54 @@ module Charming
|
|
|
71
71
|
:handled
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
-
#
|
|
75
|
-
#
|
|
74
|
+
# Hit-tests the current mouse event against named layout panes from the latest render.
|
|
75
|
+
# Clicks move focus to matching slots; components in clicked panes receive local coordinates.
|
|
76
76
|
def dispatch_component_mouse
|
|
77
|
-
|
|
77
|
+
target = mouse_target_for_event
|
|
78
|
+
return nil unless target
|
|
79
|
+
|
|
80
|
+
slot = target.fetch(:name)
|
|
81
|
+
previous_focus = focus.current
|
|
82
|
+
focus.focus(slot) if focusable_click?(slot)
|
|
83
|
+
|
|
84
|
+
result = dispatch_mouse_to_target_component(slot, target)
|
|
85
|
+
return response if result.nil? && previous_focus == focus.current
|
|
86
|
+
|
|
87
|
+
result ? dispatch_component_result(slot, result) : render_default_action
|
|
88
|
+
response
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def mouse_target_for_event
|
|
92
|
+
mouse_targets.rfind { |target| target.fetch(:rect).cover?(event.x, event.y) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def focusable_click?(slot)
|
|
96
|
+
event.respond_to?(:click?) && event.click? && focus.ring.include?(slot)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def dispatch_mouse_to_target_component(slot, target)
|
|
100
|
+
return nil unless respond_to?(slot, true)
|
|
101
|
+
|
|
102
|
+
component = send(slot)
|
|
103
|
+
return nil unless component.respond_to?(:handle_mouse)
|
|
104
|
+
|
|
105
|
+
local_event = local_mouse_event(target.fetch(:inner_rect))
|
|
106
|
+
return nil unless local_event
|
|
107
|
+
|
|
108
|
+
component.handle_mouse(local_event)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def local_mouse_event(rect)
|
|
112
|
+
return nil unless rect.cover?(event.x, event.y)
|
|
113
|
+
|
|
114
|
+
Events::MouseEvent.new(
|
|
115
|
+
button: event.button,
|
|
116
|
+
x: event.x - rect.x,
|
|
117
|
+
y: event.y - rect.y,
|
|
118
|
+
ctrl: event.ctrl,
|
|
119
|
+
alt: event.alt,
|
|
120
|
+
shift: event.shift
|
|
121
|
+
)
|
|
78
122
|
end
|
|
79
123
|
end
|
|
80
124
|
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
class Controller
|
|
5
|
+
# Focus manages a stack of focus scopes (rings) for a single controller class. Each scope has
|
|
6
|
+
# a slot ring (a fixed list of named slots) and a current slot within that ring. Multiple
|
|
7
|
+
# scopes can be stacked so the command palette, modals, and layouts can each have their own
|
|
8
|
+
# focus contexts without interfering with one another.
|
|
9
|
+
#
|
|
10
|
+
# State lives under `session[:focus_state][controller_class_name]` so focus persists across
|
|
11
|
+
# controller dispatches within the same session.
|
|
12
|
+
class Focus
|
|
13
|
+
# Returns the Focus object for *controller_class* under the given *session*, creating the
|
|
14
|
+
# underlying session hash if absent.
|
|
15
|
+
def self.for(session, controller_class)
|
|
16
|
+
session[:focus_state] ||= {}
|
|
17
|
+
key = controller_class.name
|
|
18
|
+
session[:focus_state][key] ||= {scopes: []}
|
|
19
|
+
new(session[:focus_state][key])
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(state)
|
|
23
|
+
@state = state
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Defines the primary focus ring for the controller with the given *slots*. Only effective
|
|
27
|
+
# the first time it is called; subsequent calls are no-ops.
|
|
28
|
+
def define(slots)
|
|
29
|
+
return if @state[:scopes].any? { |scope| scope[:origin] == :ring }
|
|
30
|
+
|
|
31
|
+
@state[:scopes] << build_scope(slots, :ring)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Defines a layout scope (inserted after the primary ring and before modal scopes). *slots*
|
|
35
|
+
# is the list of pane names; the previously-focused layout slot is preserved when it is still
|
|
36
|
+
# part of the new ring.
|
|
37
|
+
def define_layout(slots)
|
|
38
|
+
current = current_layout_slot(slots)
|
|
39
|
+
remove_scope(:layout)
|
|
40
|
+
return if slots.empty?
|
|
41
|
+
|
|
42
|
+
@state[:scopes].insert(layout_scope_index, build_scope(slots, :layout, current))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Pushes a new focus scope with the given *slots* onto the stack. Used by modals, palettes,
|
|
46
|
+
# and other overlays. *origin* is a label for the scope kind.
|
|
47
|
+
def push_scope(slots, origin: :modal)
|
|
48
|
+
@state[:scopes] << build_scope(slots, origin)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Pops the topmost focus scope from the stack.
|
|
52
|
+
def pop_scope
|
|
53
|
+
@state[:scopes].pop
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns the currently focused slot, or nil when no scope is active.
|
|
57
|
+
def current
|
|
58
|
+
top && top[:current]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns the slot ring of the topmost scope (an array of slot names). Empty when no scope.
|
|
62
|
+
def ring
|
|
63
|
+
top ? top[:ring] : []
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Sets the current slot within the topmost scope to *slot*. No-op when *slot* is not in the ring.
|
|
67
|
+
def focus(slot)
|
|
68
|
+
return unless ring.include?(slot)
|
|
69
|
+
|
|
70
|
+
top[:current] = slot
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Cycles focus by *direction* (default +1 forward) within the topmost ring. No-op on an empty ring.
|
|
74
|
+
def cycle(direction = +1)
|
|
75
|
+
return if ring.empty?
|
|
76
|
+
|
|
77
|
+
index = ring.index(current) || 0
|
|
78
|
+
top[:current] = ring[(index + direction) % ring.length]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# True when *slot* is the current focus slot.
|
|
82
|
+
def focused?(slot)
|
|
83
|
+
current == slot
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Returns the topmost scope hash (the last entry pushed onto `@state[:scopes]`).
|
|
89
|
+
def top
|
|
90
|
+
@state[:scopes].last
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Removes every scope whose origin equals *origin* (in place).
|
|
94
|
+
def remove_scope(origin)
|
|
95
|
+
@state[:scopes].reject! { |scope| scope[:origin] == origin }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Returns the index in the scope stack where a layout scope belongs: just before the first
|
|
99
|
+
# non-ring, non-layout scope (i.e., at the end of the "structural" stack).
|
|
100
|
+
def layout_scope_index
|
|
101
|
+
index = @state[:scopes].index { |scope| !%i[ring layout].include?(scope[:origin]) }
|
|
102
|
+
index || @state[:scopes].length
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns the current layout scope's current slot, but only when it is still part of *slots*.
|
|
106
|
+
# Otherwise returns the first slot in *slots* (so a new layout reverts to its first pane).
|
|
107
|
+
def current_layout_slot(slots)
|
|
108
|
+
current_slot = current_layout_scope&.fetch(:current)
|
|
109
|
+
slots.include?(current_slot) ? current_slot : slots.first
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns the layout scope, or nil when no layout scope is present.
|
|
113
|
+
def current_layout_scope
|
|
114
|
+
@state[:scopes].find { |scope| scope[:origin] == :layout }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Builds an immutable scope hash with the given *slots*, *origin*, and starting *current* slot.
|
|
118
|
+
def build_scope(slots, origin, current = slots.first)
|
|
119
|
+
{ring: slots.dup.freeze, current: current, origin: origin}
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -9,7 +9,7 @@ module Charming
|
|
|
9
9
|
# Returns the per-controller Focus object, defining the focus ring from class-level DSL
|
|
10
10
|
# declarations on first access.
|
|
11
11
|
def focus
|
|
12
|
-
@focus ||= Focus.for(session, self.class).tap do |f|
|
|
12
|
+
@focus ||= Controller::Focus.for(session, self.class).tap do |f|
|
|
13
13
|
f.define(self.class.focus_ring_slots) unless self.class.focus_ring_slots.empty?
|
|
14
14
|
end
|
|
15
15
|
end
|
|
@@ -96,12 +96,9 @@ module Charming
|
|
|
96
96
|
def conventional_view_constant_path(name)
|
|
97
97
|
parts = name.to_s.split("/")
|
|
98
98
|
action = parts.pop
|
|
99
|
-
|
|
100
|
-
end
|
|
99
|
+
view_name = "#{ActiveSupport::Inflector.camelize(action.to_s)}View"
|
|
101
100
|
|
|
102
|
-
|
|
103
|
-
def camelize(value)
|
|
104
|
-
value.to_s.split("_").map(&:capitalize).join
|
|
101
|
+
parts.map { |part| ActiveSupport::Inflector.camelize(part) } + [view_name]
|
|
105
102
|
end
|
|
106
103
|
|
|
107
104
|
# Returns the default template path for a given *action* (e.g., "home/show" for HomeController#show).
|
|
@@ -111,16 +108,8 @@ module Charming
|
|
|
111
108
|
|
|
112
109
|
# Returns the underscored controller path (e.g., "home" for HomeController) used for view lookup.
|
|
113
110
|
def controller_template_path
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
# Converts CamelCase to snake_case.
|
|
118
|
-
def underscore(value)
|
|
119
|
-
value
|
|
120
|
-
.gsub(/([A-Z]+)([A-Z][a-z])/, "\\1_\\2")
|
|
121
|
-
.gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
|
|
122
|
-
.tr("-", "_")
|
|
123
|
-
.downcase
|
|
111
|
+
controller_name = ActiveSupport::Inflector.demodulize(self.class.name).delete_suffix("Controller")
|
|
112
|
+
ActiveSupport::Inflector.underscore(controller_name)
|
|
124
113
|
end
|
|
125
114
|
end
|
|
126
115
|
end
|
|
@@ -11,6 +11,17 @@ module Charming
|
|
|
11
11
|
application.session
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
# Stores the named layout panes from the latest render so mouse events can be hit-tested
|
|
15
|
+
# against the same focus slots used by Tab traversal.
|
|
16
|
+
def register_mouse_targets(targets)
|
|
17
|
+
session[:mouse_targets] = targets
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns the named layout panes from the latest render.
|
|
21
|
+
def mouse_targets
|
|
22
|
+
session.fetch(:mouse_targets, [])
|
|
23
|
+
end
|
|
24
|
+
|
|
14
25
|
# Returns the named session-backed state object, creating it on first access. *name* is a
|
|
15
26
|
# symbol key under `session[:states]`. *state_class* is an ApplicationState subclass whose
|
|
16
27
|
# constructor receives *attributes* on first creation. Subsequent calls return the same object.
|
data/lib/charming/controller.rb
CHANGED
|
@@ -67,8 +67,11 @@ module Charming
|
|
|
67
67
|
# Mouse event dispatcher: checks command palette (if open), sidebar (if focused).
|
|
68
68
|
def dispatch_mouse
|
|
69
69
|
return dispatch_command_palette_mouse if command_palette_open?
|
|
70
|
-
|
|
71
|
-
dispatch_component_mouse
|
|
70
|
+
|
|
71
|
+
mouse_response = dispatch_component_mouse
|
|
72
|
+
return mouse_response if mouse_response
|
|
73
|
+
|
|
74
|
+
dispatch_sidebar_mouse if sidebar_focused?
|
|
72
75
|
end
|
|
73
76
|
|
|
74
77
|
# Renders a body or template wrapped in the controller's layout.
|
|
@@ -97,6 +100,12 @@ module Charming
|
|
|
97
100
|
application.use_theme(name)
|
|
98
101
|
end
|
|
99
102
|
|
|
103
|
+
# Returns the application logger. The default logger writes to File::NULL, so logging calls are
|
|
104
|
+
# safe in TUI code unless the app explicitly configures a file or custom logger.
|
|
105
|
+
def logger
|
|
106
|
+
application.logger
|
|
107
|
+
end
|
|
108
|
+
|
|
100
109
|
# Opens the theme picker (a CommandPalette populated with the registered themes) and renders.
|
|
101
110
|
def open_theme_palette
|
|
102
111
|
session[:command_palette] = command_palette_state(:themes)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Charming
|
|
6
|
+
module Database
|
|
7
|
+
# Commands implements the runtime side of `charming db:COMMAND` (other than
|
|
8
|
+
# `db:install`, which lives in Generators::DatabaseInstaller). It loads the app's
|
|
9
|
+
# `config/database.rb`, delegates the actual work to ActiveRecord, and prints a short
|
|
10
|
+
# status line on success.
|
|
11
|
+
class Commands
|
|
12
|
+
# *command* is the subcommand string (e.g., "db:create"). *out* is the status-output
|
|
13
|
+
# stream. *destination* is the app root for resolving `config/database.rb` and `db/`.
|
|
14
|
+
def initialize(command, out:, destination:)
|
|
15
|
+
@command = command
|
|
16
|
+
@out = out
|
|
17
|
+
@destination = destination
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Dispatches the configured command. Raises Generators::Error for unknown commands.
|
|
21
|
+
def run
|
|
22
|
+
case command
|
|
23
|
+
when "db:create" then create
|
|
24
|
+
when "db:migrate" then migrate
|
|
25
|
+
when "db:rollback" then rollback
|
|
26
|
+
when "db:drop" then drop
|
|
27
|
+
when "db:seed" then seed
|
|
28
|
+
else raise Generators::Error, "Unknown database command: #{command}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# The subcommand, output stream, and app destination.
|
|
35
|
+
attr_reader :command, :out, :destination
|
|
36
|
+
|
|
37
|
+
# Creates the SQLite database file (touch) and establishes the connection.
|
|
38
|
+
def create
|
|
39
|
+
load_database
|
|
40
|
+
FileUtils.mkdir_p(File.dirname(database_path)) if database_path
|
|
41
|
+
FileUtils.touch(database_path) if database_path
|
|
42
|
+
ActiveRecord::Base.connection
|
|
43
|
+
out.puts "create #{relative_database_path}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Runs all pending migrations from `db/migrate`.
|
|
47
|
+
def migrate
|
|
48
|
+
load_database
|
|
49
|
+
migration_context.migrate
|
|
50
|
+
out.puts "migrate db/migrate"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Rolls back the most recent migration.
|
|
54
|
+
def rollback
|
|
55
|
+
load_database
|
|
56
|
+
migration_context.rollback(1)
|
|
57
|
+
out.puts "rollback db/migrate"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Disconnects ActiveRecord, then deletes the database file.
|
|
61
|
+
def drop
|
|
62
|
+
load_database
|
|
63
|
+
ActiveRecord::Base.connection.disconnect!
|
|
64
|
+
File.delete(database_path) if database_path && File.exist?(database_path)
|
|
65
|
+
out.puts "drop #{relative_database_path}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Loads `db/seeds.rb` (raises if missing).
|
|
69
|
+
def seed
|
|
70
|
+
load_database
|
|
71
|
+
seed_path = File.join(destination, "db", "seeds.rb")
|
|
72
|
+
raise Generators::Error, "Missing file: db/seeds.rb" unless File.exist?(seed_path)
|
|
73
|
+
|
|
74
|
+
load seed_path
|
|
75
|
+
out.puts "seed db/seeds.rb"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Loads the app's `config/database.rb` (raises if missing) which establishes the connection.
|
|
79
|
+
def load_database
|
|
80
|
+
database_config = File.join(destination, "config", "database.rb")
|
|
81
|
+
raise Generators::Error, "Database support is not configured. Missing config/database.rb." unless File.exist?(database_config)
|
|
82
|
+
|
|
83
|
+
require database_config
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The ActiveRecord migration context rooted at `db/migrate` inside the app.
|
|
87
|
+
def migration_context
|
|
88
|
+
ActiveRecord::MigrationContext.new(File.join(destination, "db", "migrate"))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The configured database file path (nil when ActiveRecord isn't connected to a file).
|
|
92
|
+
def database_path
|
|
93
|
+
ActiveRecord::Base.connection_db_config.database
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The database path relative to the app root, used for human-friendly status output.
|
|
97
|
+
def relative_database_path
|
|
98
|
+
return "database" unless database_path
|
|
99
|
+
|
|
100
|
+
base = File.realpath(destination)
|
|
101
|
+
path = File.expand_path(database_path)
|
|
102
|
+
path.start_with?("#{base}/") ? path.delete_prefix("#{base}/") : path
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Charming
|
|
6
|
+
module Generators
|
|
7
|
+
# DatabaseInstaller implements `charming db:install sqlite3`. It adds database support
|
|
8
|
+
# to an existing Charming app by creating `config/database.rb`, `app/models/application_record.rb`,
|
|
9
|
+
# `db/migrate/`, and `db/seeds.rb`, and patching the gemspec and root loader to include
|
|
10
|
+
# the new dependencies and the `app/models` autoload directory.
|
|
11
|
+
class DatabaseInstaller
|
|
12
|
+
# *database* is the adapter name (only "sqlite3" is currently supported). *out* is the
|
|
13
|
+
# status-output stream. *destination* is the app root.
|
|
14
|
+
def initialize(database, out:, destination:)
|
|
15
|
+
@database = database
|
|
16
|
+
@out = out
|
|
17
|
+
@destination = destination
|
|
18
|
+
@app_name = Name.new(app_name_from_gemspec)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Performs the install: writes the database config, application record, migrate directory,
|
|
22
|
+
# seeds file, and patches the gemspec + root loader. Idempotent: existing files are
|
|
23
|
+
# reported with "exist <path>" instead of being overwritten.
|
|
24
|
+
def install
|
|
25
|
+
raise Error, "Unsupported database: #{database.inspect}" unless database == "sqlite3"
|
|
26
|
+
|
|
27
|
+
create_file("config/database.rb", database_config)
|
|
28
|
+
create_file("app/models/application_record.rb", application_record)
|
|
29
|
+
create_file("db/migrate/.keep", "")
|
|
30
|
+
create_file("db/seeds.rb", %(# frozen_string_literal: true
|
|
31
|
+
))
|
|
32
|
+
update_gemspec
|
|
33
|
+
update_root_file
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# The database adapter, status stream, app destination, and derived app name.
|
|
39
|
+
attr_reader :database, :out, :destination, :app_name
|
|
40
|
+
|
|
41
|
+
# Writes *content* to *path* (relative to the app root), creating intermediate directories.
|
|
42
|
+
# Reports "exist <path>" without overwriting when the file already exists.
|
|
43
|
+
def create_file(path, content)
|
|
44
|
+
absolute_path = File.join(destination, path)
|
|
45
|
+
if File.exist?(absolute_path)
|
|
46
|
+
out.puts "exist #{path}"
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
FileUtils.mkdir_p(File.dirname(absolute_path))
|
|
51
|
+
File.write(absolute_path, content)
|
|
52
|
+
out.puts "create #{path}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Patches the gemspec to include the `db` directory in the gem files glob and to add
|
|
56
|
+
# activerecord + sqlite3 dependencies.
|
|
57
|
+
def update_gemspec
|
|
58
|
+
update_file(gemspec_path) do |current|
|
|
59
|
+
updated = current.sub('Dir.glob("{app,config,exe,lib}/**/*")', 'Dir.glob("{app,config,db,exe,lib}/**/*")')
|
|
60
|
+
updated = insert_dependency(updated, "activerecord", "~> 8.1")
|
|
61
|
+
insert_dependency(updated, "sqlite3", "~> 2.0")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Patches the root loader file (`lib/<app>.rb`) to require `config/database` and to push
|
|
66
|
+
# the `app/models` autoload directory. Both edits are no-ops when already applied.
|
|
67
|
+
def update_root_file
|
|
68
|
+
update_file(root_file_path) do |current|
|
|
69
|
+
updated = current
|
|
70
|
+
updated = updated.sub(%(require "zeitwerk"\n), %(require "zeitwerk"\nrequire_relative "../config/database"\n)) unless updated.include?(%(require_relative "../config/database"))
|
|
71
|
+
unless updated.include?(%[loader.push_dir(File.expand_path("../app/models", __dir__), namespace: #{app_name.class_name})])
|
|
72
|
+
updated = updated.sub(
|
|
73
|
+
%[loader.push_dir(File.expand_path("../app/state", __dir__), namespace: #{app_name.class_name})\n],
|
|
74
|
+
%[loader.push_dir(File.expand_path("../app/models", __dir__), namespace: #{app_name.class_name})\nloader.push_dir(File.expand_path("../app/state", __dir__), namespace: #{app_name.class_name})\n]
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
updated
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Reads *path*, yields its contents to the block, and writes the result back when it
|
|
82
|
+
# differs. Raises Error when the file is missing.
|
|
83
|
+
def update_file(path)
|
|
84
|
+
raise Error, "Missing file: #{relative_path(path)}" unless File.exist?(path)
|
|
85
|
+
|
|
86
|
+
current = File.read(path)
|
|
87
|
+
updated = yield current
|
|
88
|
+
return if updated == current
|
|
89
|
+
|
|
90
|
+
File.write(path, updated)
|
|
91
|
+
out.puts "update #{relative_path(path)}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Inserts a `spec.add_dependency "name", "version"` line after the `charming` dependency
|
|
95
|
+
# when it's not already present.
|
|
96
|
+
def insert_dependency(content, gem_name, version)
|
|
97
|
+
return content if content.include?(%(spec.add_dependency "#{gem_name}"))
|
|
98
|
+
|
|
99
|
+
dependency = %( spec.add_dependency "#{gem_name}", "#{version}")
|
|
100
|
+
content.sub(%( spec.add_dependency "charming"\n), %( spec.add_dependency "charming"\n#{dependency}\n))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The contents of the new `config/database.rb` (establishes an SQLite connection to
|
|
104
|
+
# `db/development.sqlite3`).
|
|
105
|
+
def database_config
|
|
106
|
+
%(# frozen_string_literal: true
|
|
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
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The contents of the new `app/models/application_record.rb` (abstract ActiveRecord base).
|
|
122
|
+
def application_record
|
|
123
|
+
%(# frozen_string_literal: true
|
|
124
|
+
|
|
125
|
+
module #{app_name.class_name}
|
|
126
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
127
|
+
self.abstract_class = true
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Reads the app's gemspec filename to derive the app name.
|
|
134
|
+
def app_name_from_gemspec
|
|
135
|
+
File.basename(gemspec_path, ".gemspec")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The path to the app's gemspec (raises when not found).
|
|
139
|
+
def gemspec_path
|
|
140
|
+
@gemspec_path ||= Dir.glob(File.join(destination, "*.gemspec")).first || raise(Error, "Run this command from a Charming app root")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The path to the app's root loader file (`lib/<app_name>.rb`).
|
|
144
|
+
def root_file_path
|
|
145
|
+
File.join(destination, "lib", "#{app_name.snake_name}.rb")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Strips the app destination prefix from *path* for human-friendly status output.
|
|
149
|
+
def relative_path(path)
|
|
150
|
+
path.delete_prefix("#{destination}/")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -103,20 +103,12 @@ module Charming
|
|
|
103
103
|
|
|
104
104
|
# The pluralized table name (e.g., "user" → "users", "category" → "categories").
|
|
105
105
|
def table_name
|
|
106
|
-
pluralize(name.snake_name)
|
|
106
|
+
ActiveSupport::Inflector.pluralize(name.snake_name)
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
# The CamelCase migration class name (e.g., "users" → "Users").
|
|
110
110
|
def table_class_name
|
|
111
|
-
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
# Minimal English pluralization for the model name (covers the common -y, -s/x/z/ch/sh cases).
|
|
115
|
-
def pluralize(value)
|
|
116
|
-
return value.sub(/y\z/, "ies") if value.end_with?("y")
|
|
117
|
-
return "#{value}es" if value.match?(/(?:s|x|z|ch|sh)\z/)
|
|
118
|
-
|
|
119
|
-
"#{value}s"
|
|
111
|
+
ActiveSupport::Inflector.camelize(table_name)
|
|
120
112
|
end
|
|
121
113
|
|
|
122
114
|
# The current UTC timestamp in the format ActiveRecord uses for migration filenames.
|
|
@@ -21,7 +21,7 @@ module Charming
|
|
|
21
21
|
|
|
22
22
|
# The CamelCase class name (e.g., "user" → "User").
|
|
23
23
|
def class_name
|
|
24
|
-
|
|
24
|
+
ActiveSupport::Inflector.camelize(snake_name)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
# The controller class name (e.g., "user" → "UserController").
|