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 +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +9 -3
- data/lib/canvas_erd/cli.rb +4 -1
- data/lib/canvas_erd/diagram_store.rb +16 -2
- data/lib/canvas_erd/highlighter.rb +57 -0
- data/lib/canvas_erd/model_details.rb +105 -0
- data/lib/canvas_erd/server.rb +79 -4
- data/lib/canvas_erd/source_files.rb +70 -0
- data/lib/canvas_erd/version.rb +1 -2
- data/lib/canvas_erd/web/app.js +1437 -91
- data/lib/canvas_erd/web/document.js +532 -2
- data/lib/canvas_erd/web/index.html +215 -1
- data/lib/canvas_erd/web/styles.css +295 -0
- data/lib/canvas_erd.rb +3 -0
- metadata +26 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 56efaf62a9da71464c99f1bbe4ad6e3b384d5268b62bdb056b68b7105f0b5ab3
|
|
4
|
+
data.tar.gz: a2c50f1c6408f5e19178eb7f70b476c0cb0d6376364601b745cf06a3ec510388
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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**.
|
|
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
|
|
data/lib/canvas_erd/cli.rb
CHANGED
|
@@ -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
|
data/lib/canvas_erd/server.rb
CHANGED
|
@@ -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"]
|
|
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
|