canvas_erd 0.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b593a4a8e3ebb6c820c3469cc0063acb4fdf129313e2ea2c68da33a15166be2
4
- data.tar.gz: da8d50366c574f574289cf59046e98954fdf324ceff1f20d3ba2811349a5b35e
3
+ metadata.gz: 56efaf62a9da71464c99f1bbe4ad6e3b384d5268b62bdb056b68b7105f0b5ab3
4
+ data.tar.gz: a2c50f1c6408f5e19178eb7f70b476c0cb0d6376364601b745cf06a3ec510388
5
5
  SHA512:
6
- metadata.gz: 8262355aa351a042ea353f9d1340aee15d30e839f29bcdea12c3d3d728070d176deb0cb845db2b43e7c1f0cb05cd8321a8480e00286e665379b1b368640c0f31
7
- data.tar.gz: a4db10469eaf6ee4f1d95afbcc7f02f826c37f0234187d02d925c79d73f4c65f86c9fe5dbb8e08d6e46929189c6e53d5695f81f05e185d2a01c73349e88b1776
6
+ metadata.gz: 0a161a5b80937c795fa467d9cd6e0a7a6828dbaf6de2453f5e05728fa080baa2165ec05189c8adcfd2ca4df8d84a06ba77b501e5c432014f77f270f0307dc324
7
+ data.tar.gz: 61c7efe55ee748ac1768b29abe5018c396f9d64a528ee4bf1098cbdfcb8d1beeee30049a9d3981f87a639fe42cfdfee6e16a251a91b32900b36e17adbf9f2416
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-10-12
4
+
5
+ - Add syntax-highlighted code boxes with a selectable language and small, medium, or large font, highlighted with Rouge.
6
+ - Add read-only source file snippets from the Rails application, with `.gitignore`-aware path autocompletion, optional start and end lines, toggleable line numbers, and clickable line highlighting; **Refresh schema** re-reads them.
7
+ - Toggle a model's scopes from the table sidebar to show their source in a code box linked to its table, limited to scopes declared in the Rails application and its concerns.
8
+ - Require Ruby 3.3 or newer.
9
+
10
+ ## 0.2.0 - 2026-09-19
11
+
12
+ - Format whole notes with paragraph styles, bold, italic, underline, alignment, text and background colors, and keyboard shortcuts for styles and emphasis.
13
+ - Choose colors from common options or custom values currently used in the diagram, including transparent note backgrounds and background-aware borders.
14
+ - Color the headers of one or more selected tables to visually group related tables, with preset and custom colors.
15
+ - Enter arrow endpoint editing immediately when an arrow is selected, without requiring a double-click.
16
+ - Style arrows with independent endpoint heads, solid, dashed, or dotted lines, adjustable widths, and foreground-palette or custom colors.
17
+ - Draw backmost grouping frames with editable labels, foreground-palette or custom colors, and solid, dashed, or dotted borders.
18
+ - Render association cardinalities with table-edge-aligned crow's-foot notation, retaining text for nonstandard numeric ranges.
19
+ - Open the most recently modified saved diagram when the editor starts.
20
+
3
21
  ## 0.1.0 - 2026-08-30
4
22
 
5
23
  - Generate a JSON-safe diagram schema from Rails ERD domain models.
data/README.md CHANGED
@@ -15,7 +15,7 @@ group :development do
15
15
  end
16
16
  ```
17
17
 
18
- Then run `bundle install`. CanvasERD requires Ruby 3.1 or newer, Active Record 7.0 or newer, and a Rack server available in the Rails bundle.
18
+ Then run `bundle install`. CanvasERD requires Ruby 3.3 or newer, Active Record 7.0 or newer, and a Rack server available in the Rails bundle.
19
19
 
20
20
  ## Usage
21
21
 
@@ -35,16 +35,22 @@ Diagrams are saved by the editor as `*.erd.png` files under `docs/erd/` in the R
35
35
  bundle exec canvas_erd --diagrams-dir db/diagrams
36
36
  ```
37
37
 
38
- The editor's saved-diagram menu loads these files directly. You can also open a diagram when starting CanvasERD with `bundle exec canvas_erd path/to/domain.erd.png`.
38
+ The editor opens the most recently modified saved diagram when it starts. Its saved-diagram menu loads other files directly. You can also open a specific diagram with `bundle exec canvas_erd path/to/domain.erd.png`.
39
39
 
40
40
  ## Editor controls
41
41
 
42
42
  - Drag tables to reposition them.
43
43
  - Use the table sidebar to add or remove tables without losing their positions.
44
+ - Toggle **scopes** in the table sidebar to show a model's scope source in a code box linked to its table. Only scopes declared in the Rails application (including its concerns) are shown.
45
+ - Select one or more tables to color their headers and visually group related tables.
44
46
  - Use **Layout tables** to rearrange the tables currently on the canvas with ELK without moving notes or hidden tables.
45
47
  - Pinch over the canvas or use the toolbar buttons to zoom.
46
48
  - Use a two-finger trackpad gesture to pan. Space-drag remains available as a keyboard fallback, and **Fit diagram** resets the view.
47
- - Add editable notes with **Add note**. Select a note and press Delete to remove it.
49
+ - Add editable notes with **Add note**. Notes support paragraph styles, emphasis, alignment, and text and background colors; new notes inherit the last touched note's style.
50
+ - Add syntax-highlighted code boxes with **Add code**, choosing the language and a small, medium, or large font from the formatting toolbar. **Add source file** shows a read-only snippet of a file in the Rails application, with path autocompletion from the application's files (respecting `.gitignore`) and optional start and end lines. Select the snippet to change its path or lines from the toolbar, or to toggle line numbers (which follow the source file). Click line numbers to highlight lines, and Shift-click to highlight or clear a range to match the last line clicked; **Refresh schema** re-reads it. Highlighting uses [Rouge](https://github.com/rouge-ruby/rouge).
51
+ - Draw and style arrows with independent endpoint heads. Selecting an arrow immediately exposes its movable endpoints.
52
+ - Draw grouping frames behind the diagram, then edit their labels, colors, and border styles.
53
+ - Relationship cardinalities use crow's-foot notation at table edges, with exact text retained for uncommon numeric ranges.
48
54
  - Use **Refresh schema** after a database migration to update columns and relationships. Existing table positions, selections, notes, and the current view are preserved; new tables start unselected.
49
55
  - Edit the diagram name, then use **Save** (or <kbd>⌘S</kbd>/<kbd>Ctrl-S</kbd>) to write the complete diagram into the Rails application as a normal `*.erd.png` image with its editable schema, layout, notes, selection, and viewport embedded inside it. Select a saved diagram from the menu to reopen it, or use **New** to start an “Untitled ERD.”
50
56
 
@@ -72,6 +72,7 @@ module CanvasERD
72
72
  end
73
73
 
74
74
  @err.puts "Loading Rails application from #{@root}..."
75
+ ModelDetails.install
75
76
  ApplicationLoader.new(@root).load
76
77
 
77
78
  schema_provider = SchemaProvider.new
@@ -87,7 +88,9 @@ module CanvasERD
87
88
  schema: schema,
88
89
  schema_provider: schema_provider,
89
90
  state: state,
90
- diagram_store: diagram_store
91
+ diagram_store: diagram_store,
92
+ source_files: SourceFiles.new(root: @root),
93
+ model_details: ModelDetails.new(root: @root)
91
94
  )
92
95
  server = Server.new(app: app)
93
96
 
@@ -31,10 +31,23 @@ module CanvasERD
31
31
  Dir.children(directory).select { |name| valid_filename?(name) && File.file?(path_for(name)) }.sort
32
32
  end
33
33
 
34
+ def most_recent_name
35
+ names.max_by { |name| [File.mtime(path_for(name)).to_f, name] }
36
+ rescue SystemCallError => error
37
+ raise Error, "Could not inspect saved diagrams: #{error.message}"
38
+ end
39
+
40
+ def document(name)
41
+ read_diagram(name).last
42
+ end
43
+
34
44
  def read(name)
45
+ read_diagram(name).first
46
+ end
47
+
48
+ def read_diagram(name)
35
49
  bytes = File.binread(path_for(name))
36
- PngDocument.read(StringIO.new(bytes))
37
- bytes
50
+ [bytes, PngDocument.read(StringIO.new(bytes))]
38
51
  rescue Errno::ENOENT
39
52
  raise NotFound, "Diagram not found: #{name}"
40
53
  rescue SystemCallError => error
@@ -42,6 +55,7 @@ module CanvasERD
42
55
  rescue PngDocument::Error => error
43
56
  raise Error, error.message
44
57
  end
58
+ private :read_diagram
45
59
 
46
60
  def write(name, bytes)
47
61
  path = path_for(name)
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rouge"
4
+
5
+ module CanvasERD
6
+ class Highlighter
7
+ def initialize(theme: Rouge::Themes::Github.new)
8
+ @theme = theme
9
+ end
10
+
11
+ def languages
12
+ Rouge::Lexer.all
13
+ .map { |lexer| { "tag" => lexer.tag, "title" => lexer.title } }
14
+ .sort_by { |language| language["title"].downcase }
15
+ end
16
+
17
+ def colors
18
+ style = @theme.style_for(Rouge::Token::Tokens::Text) || {}
19
+ { "foreground" => color(style[:fg]), "background" => color(style[:bg]) }
20
+ end
21
+
22
+ def language_for(filename)
23
+ Rouge::Lexer.guess(filename: filename) { Rouge::Lexers::PlainText }.tag
24
+ rescue Rouge::Guesser::Ambiguous
25
+ Rouge::Lexers::PlainText.tag
26
+ end
27
+
28
+ def highlight(code, language)
29
+ code = normalize(code)
30
+ lexer = Rouge::Lexer.find(language.to_s) || Rouge::Lexers::PlainText
31
+ background = colors["background"]
32
+ runs = lexer.lex(code).map { |token, text| [text, run_style(token, background)] }
33
+ { "code" => code, "language" => lexer.tag, "runs" => runs }
34
+ end
35
+
36
+ def normalize(code)
37
+ code.to_s.gsub(/\r\n?/, "\n").gsub("\t", " ")
38
+ end
39
+
40
+ private
41
+
42
+ def run_style(token, background)
43
+ style = @theme.style_for(token) || {}
44
+ result = {}
45
+ result["fill"] = color(style[:fg]) if style[:fg]
46
+ result["fontWeight"] = "bold" if style[:bold]
47
+ result["fontStyle"] = "italic" if style[:italic]
48
+ token_background = color(style[:bg])
49
+ result["textBackgroundColor"] = token_background if token_background && token_background != background
50
+ result
51
+ end
52
+
53
+ def color(value)
54
+ value.is_a?(Symbol) ? @theme.class.palette(value) : value
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module CanvasERD
6
+ class ModelDetails
7
+ class NotFound < StandardError; end
8
+
9
+ DETAILS = %w[scopes].freeze
10
+
11
+ module ScopeRecorder
12
+ def scope(name, body, &block)
13
+ location = caller_locations(1, 1).first
14
+ ModelDetails.record_scope(self, name, location.absolute_path || location.path, location.lineno)
15
+ super
16
+ end
17
+ end
18
+
19
+ @scopes = Hash.new { |scopes, model| scopes[model] = [] }
20
+
21
+ class << self
22
+ attr_reader :scopes
23
+
24
+ def install
25
+ require "active_support/lazy_load_hooks"
26
+ ActiveSupport.on_load(:active_record) { singleton_class.prepend(ScopeRecorder) }
27
+ end
28
+
29
+ def record_scope(model, name, path, line)
30
+ return unless model.name
31
+
32
+ scopes[model.name] << { name: name.to_s, path: path, line: line }
33
+ end
34
+ end
35
+
36
+ def initialize(root:, scopes: self.class.scopes, source_path: ->(model) { Object.const_source_location(model)&.first })
37
+ @root = File.realpath(root)
38
+ @scopes = scopes
39
+ @source_path = source_path
40
+ end
41
+
42
+ def summary
43
+ @scopes.keys.each_with_object({}) do |model, summary|
44
+ count = application_scopes(model).length
45
+ summary[model] = { "scopes" => count } if count.positive?
46
+ end
47
+ end
48
+
49
+ def detail(model, detail)
50
+ raise NotFound, "Unknown model detail: #{detail}" unless DETAILS.include?(detail)
51
+ raise NotFound, "No #{detail} for #{model}" if application_scopes(model).empty?
52
+
53
+ rows = scope_rows(model)
54
+ { "code" => rows.map(&:first).join("\n"), "language" => "ruby", "lineNumbers" => rows.map(&:last) }
55
+ end
56
+
57
+ private
58
+
59
+ def scope_rows(model)
60
+ model_path = @source_path.call(model)
61
+ groups = application_scopes(model).group_by { |scope| scope[:path] }.map do |path, scopes|
62
+ header = path == model_path ? [] : [["# #{relative_path(path)}", nil]]
63
+ header + scopes.flat_map { |scope| snippet(path, scope) }
64
+ end
65
+ groups.each_with_index.flat_map { |rows, index| index.zero? ? rows : [["", nil], *rows] }
66
+ end
67
+
68
+ def application_scopes(model)
69
+ return [] unless @scopes.key?(model)
70
+
71
+ @scopes[model].select { |scope| scope[:path].start_with?("#{@root}#{File::SEPARATOR}") }
72
+ end
73
+
74
+ def snippet(path, scope)
75
+ lines = File.readlines(path)
76
+ node = scope_call_covering(Prism.parse_file(path).value, scope[:line])
77
+ range = node ? node.location.start_line..node.location.end_line : scope[:line]..scope[:line]
78
+ dedent(lines[(range.begin - 1)..(range.end - 1)]).zip(range)
79
+ rescue SystemCallError
80
+ [["# scope :#{scope[:name]} (source unavailable)", nil]]
81
+ end
82
+
83
+ def scope_call_covering(node, line)
84
+ if node.is_a?(Prism::CallNode) && node.name == :scope && node.receiver.nil? &&
85
+ (node.location.start_line..node.location.end_line).cover?(line)
86
+ return node
87
+ end
88
+
89
+ node.compact_child_nodes.each do |child|
90
+ found = scope_call_covering(child, line)
91
+ return found if found
92
+ end
93
+ nil
94
+ end
95
+
96
+ def dedent(lines)
97
+ indentation = lines.reject { |line| line.strip.empty? }.map { |line| line[/\A */].length }.min || 0
98
+ lines.map { |line| line.strip.empty? ? "" : line.chomp[indentation..] }
99
+ end
100
+
101
+ def relative_path(path)
102
+ path.delete_prefix("#{@root}#{File::SEPARATOR}")
103
+ end
104
+ end
105
+ end
@@ -21,10 +21,14 @@ module CanvasERD
21
21
 
22
22
  attr_reader :schema
23
23
 
24
- def initialize(schema:, schema_provider: nil, state: nil, diagram_store: nil)
24
+ def initialize(schema:, schema_provider: nil, state: nil, diagram_store: nil, source_files: nil,
25
+ model_details: nil, highlighter: Highlighter.new)
25
26
  @schema = schema
26
27
  @schema_provider = schema_provider || -> { schema }
27
28
  @diagram_store = diagram_store
29
+ @source_files = source_files
30
+ @model_details = model_details
31
+ @highlighter = highlighter
28
32
  @schema_mutex = Mutex.new
29
33
  @document = {
30
34
  "format" => "canvas_erd",
@@ -42,10 +46,10 @@ module CanvasERD
42
46
 
43
47
  body = [] if method == "HEAD"
44
48
  [status, security_headers.merge(headers), body]
45
- rescue DiagramStore::NotFound => error
49
+ rescue DiagramStore::NotFound, SourceFiles::NotFound, ModelDetails::NotFound => error
46
50
  status, headers, body = response(404, "text/plain; charset=utf-8", error.message)
47
51
  [status, security_headers.merge(headers), method == "HEAD" ? [] : body]
48
- rescue DiagramStore::Error => error
52
+ rescue DiagramStore::Error, SourceFiles::Error => error
49
53
  status, headers, body = response(400, "text/plain; charset=utf-8", error.message)
50
54
  [status, security_headers.merge(headers), method == "HEAD" ? [] : body]
51
55
  end
@@ -59,6 +63,12 @@ module CanvasERD
59
63
  return diagram_response(environment, write: method == "PUT")
60
64
  end
61
65
 
66
+ if path == "/api/highlight"
67
+ return method_not_allowed("POST") unless method == "POST"
68
+
69
+ return highlight_response(environment)
70
+ end
71
+
62
72
  return method_not_allowed("GET, HEAD") unless %w[GET HEAD].include?(method)
63
73
 
64
74
  if path == "/api/schema"
@@ -67,6 +77,16 @@ module CanvasERD
67
77
  document_response
68
78
  elsif path == "/api/diagrams"
69
79
  diagrams_response
80
+ elsif path == "/api/highlighting"
81
+ json_response(@highlighter.colors.merge("languages" => @highlighter.languages))
82
+ elsif path == "/api/source_files"
83
+ source_files_response
84
+ elsif path == "/api/source"
85
+ source_response(environment)
86
+ elsif path == "/api/model_details"
87
+ json_response(@model_details ? @model_details.summary : {})
88
+ elsif path == "/api/model_detail"
89
+ model_detail_response(environment)
70
90
  elsif STATIC_FILES.key?(path)
71
91
  file_response(*STATIC_FILES.fetch(path))
72
92
  else
@@ -92,7 +112,16 @@ module CanvasERD
92
112
  end
93
113
 
94
114
  def document_response
95
- document = @document["state"] ? @document : @document.merge("schema" => schema)
115
+ document = if @document["state"]
116
+ @document
117
+ elsif @diagram_store && (name = @diagram_store.most_recent_name)
118
+ @diagram_store.document(name).merge(
119
+ "filename" => name,
120
+ "application_schema" => schema
121
+ )
122
+ else
123
+ @document.merge("schema" => schema)
124
+ end
96
125
  response(
97
126
  200,
98
127
  "application/json; charset=utf-8",
@@ -130,6 +159,52 @@ module CanvasERD
130
159
  end
131
160
  end
132
161
 
162
+ def highlight_response(environment)
163
+ request = JSON.parse(environment.fetch("rack.input").read)
164
+ raise JSON::ParserError, "expected an object" unless request.is_a?(Hash)
165
+
166
+ json_response(@highlighter.highlight(request["code"], request["language"]))
167
+ rescue JSON::ParserError
168
+ response(400, "text/plain; charset=utf-8", "Invalid highlight request")
169
+ end
170
+
171
+ def source_response(environment)
172
+ return response(404, "text/plain; charset=utf-8", "Source files are not configured") unless @source_files
173
+
174
+ query = Rack::Utils.parse_query(environment.fetch("QUERY_STRING", ""))
175
+ path = query["path"]
176
+ code = @source_files.read(path, start_line: line_number(query["start"]), end_line: line_number(query["end"]))
177
+ json_response(
178
+ "path" => path,
179
+ "code" => @highlighter.normalize(code),
180
+ "language" => @highlighter.language_for(path)
181
+ )
182
+ end
183
+
184
+ def source_files_response
185
+ return response(404, "text/plain; charset=utf-8", "Source files are not configured") unless @source_files
186
+
187
+ json_response("paths" => @source_files.paths)
188
+ end
189
+
190
+ def model_detail_response(environment)
191
+ return response(404, "text/plain; charset=utf-8", "Model details are not configured") unless @model_details
192
+
193
+ query = Rack::Utils.parse_query(environment.fetch("QUERY_STRING", ""))
194
+ json_response(@model_details.detail(query["model"], query["detail"]))
195
+ end
196
+
197
+ def line_number(value)
198
+ return nil if value.nil? || value.empty?
199
+ raise SourceFiles::Error, "Invalid line number: #{value}" unless value.match?(/\A\d+\z/)
200
+
201
+ value.to_i
202
+ end
203
+
204
+ def json_response(data)
205
+ response(200, "application/json; charset=utf-8", JSON.generate(data), "cache-control" => "no-store")
206
+ end
207
+
133
208
  def file_response(relative_path, content_type)
134
209
  response(
135
210
  200,
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "find"
4
+ require "open3"
5
+
6
+ module CanvasERD
7
+ class SourceFiles
8
+ class Error < StandardError; end
9
+ class NotFound < Error; end
10
+
11
+ IGNORED_DIRECTORIES = %w[node_modules tmp log storage coverage vendor/bundle public/assets public/packs].freeze
12
+
13
+ def initialize(root:)
14
+ @root = File.realpath(root)
15
+ end
16
+
17
+ def paths
18
+ output, status = Open3.capture2(
19
+ "git", "ls-files", "-z", "--cached", "--others", "--exclude-standard",
20
+ chdir: @root, err: File::NULL
21
+ )
22
+ return walked_paths unless status.success?
23
+
24
+ output.split("\0").select { |path| File.file?(File.join(@root, path)) }.sort
25
+ rescue SystemCallError
26
+ walked_paths
27
+ end
28
+
29
+ def walked_paths
30
+ paths = []
31
+ Find.find(@root) do |path|
32
+ relative_path = path.delete_prefix("#{@root}#{File::SEPARATOR}")
33
+ next if path == @root
34
+ next Find.prune if File.basename(path).start_with?(".") || IGNORED_DIRECTORIES.include?(relative_path)
35
+
36
+ paths << relative_path if File.file?(path)
37
+ end
38
+ paths.sort
39
+ end
40
+ private :walked_paths
41
+
42
+ def read(path, start_line: nil, end_line: nil)
43
+ full_path = File.realpath(path.to_s, @root)
44
+ unless full_path.start_with?("#{@root}#{File::SEPARATOR}")
45
+ raise Error, "Source file must be inside the Rails application"
46
+ end
47
+ raise Error, "Source path is not a file: #{path}" unless File.file?(full_path)
48
+
49
+ code = File.read(full_path, encoding: "UTF-8")
50
+ raise Error, "Source file is not UTF-8 text: #{path}" if !code.valid_encoding? || code.include?("\0")
51
+
52
+ lines(code, start_line, end_line)
53
+ rescue Errno::ENOENT
54
+ raise NotFound, "Source file not found: #{path}"
55
+ rescue SystemCallError => error
56
+ raise Error, "Could not read source file #{path}: #{error.message}"
57
+ end
58
+
59
+ private
60
+
61
+ def lines(code, start_line, end_line)
62
+ lines = code.lines
63
+ first = start_line || 1
64
+ last = [end_line || lines.length, lines.length].min
65
+ raise Error, "Invalid line range #{start_line}-#{end_line}" if first < 1 || (lines.any? && first > last)
66
+
67
+ Array(lines[(first - 1)..(last - 1)]).join.chomp
68
+ end
69
+ end
70
+ end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CanvasERD
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
6
-