na 1.2.84 → 1.2.86

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.
data/lib/na/benchmark.rb CHANGED
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NA
4
+ # Provides benchmarking utilities for measuring code execution time.
5
+ #
6
+ # @example Measure a block of code
7
+ # NA::Benchmark.measure('sleep') { sleep(1) }
4
8
  module Benchmark
5
9
  class << self
6
10
  attr_accessor :enabled, :timings
@@ -18,6 +22,8 @@ module NA
18
22
  #
19
23
  # @param label [String] Label for the measurement
20
24
  # @return [Object] Result of the block
25
+ # @example
26
+ # NA::Benchmark.measure('sleep') { sleep(1) }
21
27
  def measure(label)
22
28
  return yield unless @enabled
23
29
 
@@ -31,6 +37,8 @@ module NA
31
37
  # Output a performance report to STDERR
32
38
  #
33
39
  # @return [void]
40
+ # @example
41
+ # NA::Benchmark.report
34
42
  def report
35
43
  return unless @enabled
36
44
 
data/lib/na/colors.rb CHANGED
@@ -3,6 +3,9 @@
3
3
  # Cribbed from <https://github.com/flori/term-ansicolor>
4
4
  module NA
5
5
  # Terminal output color functions.
6
+ #
7
+ # @example Clear the color template cache
8
+ # NA::Color.clear_template_cache
6
9
  module Color
7
10
  # Regexp to match excape sequences
8
11
  ESCAPE_REGEX = /(?<=\[)(?:(?:(?:[349]|10)[0-9]|[0-9])?;?)+(?=m)/.freeze
@@ -199,6 +202,10 @@ module NA
199
202
  @template_cache ||= {}
200
203
  end
201
204
 
205
+ # Clears the cached compiled color templates.
206
+ # @return [void]
207
+ # @example
208
+ # NA::Color.clear_template_cache
202
209
  def clear_template_cache
203
210
  @template_cache = {}
204
211
  end
@@ -231,15 +238,15 @@ module NA
231
238
  # i: italic, x: reset (remove background, color,
232
239
  # emphasis)
233
240
  #
234
- # Also accepts {#RGB} and {#RRGGBB} strings. Put a b
241
+ # Also accepts `{#RGB}` and `{#RRGGBB}` strings. Put a b
235
242
  # before the hash to make it a background color
236
243
  #
237
244
  # @example Convert a templated string
238
- # Color.template('{Rwb}Warning:{x} {w}you look a
239
- # little {g}ill{x}')
245
+ # Color.template('\\{Rwb\\}Warning:\\{x\\} \\{w\}\you look a
246
+ # little \\{g\\}ill\\{x\\}')
240
247
  #
241
248
  # @example Convert using RGB colors
242
- # Color.template('{#f0a}This is an RGB color')
249
+ # Color.template('\\{#f0a\\}This is an RGB color')
243
250
  #
244
251
  # @param input [String, Array] The template
245
252
  # string. If this is an array, the
data/lib/na/editor.rb CHANGED
@@ -3,8 +3,12 @@
3
3
  require 'English'
4
4
 
5
5
  module NA
6
+ # Provides editor selection and argument helpers for launching text editors.
6
7
  module Editor
7
8
  class << self
9
+ # Returns the default editor command, checking environment variables and available editors.
10
+ # @param prefer_git_editor [Boolean] Prefer GIT_EDITOR over EDITOR
11
+ # @return [String, nil] Editor command or nil if not found
8
12
  def default_editor(prefer_git_editor: true)
9
13
  editor ||= if prefer_git_editor
10
14
  ENV['NA_EDITOR'] || ENV['GIT_EDITOR'] || ENV.fetch('EDITOR', nil)
@@ -29,10 +33,15 @@ module NA
29
33
  nil
30
34
  end
31
35
 
36
+ # Returns the default editor command with its arguments.
37
+ # @return [String] Editor command with arguments
32
38
  def editor_with_args
33
39
  args_for_editor(default_editor)
34
40
  end
35
41
 
42
+ # Returns the editor command with appropriate arguments for file opening.
43
+ # @param editor [String] Editor command
44
+ # @return [String] Editor command with arguments
36
45
  def args_for_editor(editor)
37
46
  return editor if editor =~ /-\S/
38
47
 
@@ -91,7 +100,12 @@ module NA
91
100
  tmpfile.unlink
92
101
  end
93
102
 
94
- input.split("\n").delete_if(&:ignore?).join("\n")
103
+ # Don't strip comments if this looks like multi-action format (has # ------ markers)
104
+ if input.include?('# ------ ')
105
+ input
106
+ else
107
+ input.split("\n").delete_if(&:ignore?).join("\n")
108
+ end
95
109
  end
96
110
 
97
111
  # Takes a multi-line string and formats it as an entry
@@ -120,6 +134,80 @@ module NA
120
134
 
121
135
  [title, note]
122
136
  end
137
+
138
+ # Format multiple actions for multi-edit
139
+ # @param actions [Array<Action>] Actions to edit
140
+ # @return [String] Formatted editor content
141
+ def format_multi_action_input(actions)
142
+ header = <<~EOF
143
+ # Instructions:
144
+ # - Edit the action text (the lines WITHOUT # comment markers)
145
+ # - DO NOT remove or edit the lines starting with "# ------"
146
+ # - Add notes on new lines after the action
147
+ # - Blank lines are ignored
148
+ #
149
+
150
+ EOF
151
+
152
+ # Use + to create a mutable string
153
+ content = +header
154
+
155
+ actions.each do |action|
156
+ # Use file_path to get the path and file_line to get the line number
157
+ content << "# ------ #{action.file_path}:#{action.file_line}\n"
158
+ content << "#{action.action}\n"
159
+ content << "#{action.note.join("\n")}\n" if action.note.any?
160
+ content << "\n" # Blank line separator
161
+ end
162
+
163
+ content
164
+ end
165
+
166
+ # Parse multi-action editor output
167
+ # @param content [String] Editor output
168
+ # @return [Hash] Hash mapping file:line to [action, note]
169
+ def parse_multi_action_output(content)
170
+ results = {}
171
+ current_file = nil
172
+ current_action = nil
173
+ current_note = []
174
+
175
+ content.lines.each do |line|
176
+ stripped = line.strip
177
+
178
+ # Check for file marker: # ------ path:line
179
+ match = stripped.match(/^# ------ (.+?):(\d+)$/)
180
+ if match
181
+ # Save previous action if exists
182
+ results[current_file] = [current_action, current_note] if current_file && current_action
183
+
184
+ # Start new action
185
+ current_file = "#{match[1]}:#{match[2]}"
186
+ current_action = nil
187
+ current_note = []
188
+ next
189
+ end
190
+
191
+ # Skip other comment lines
192
+ next if stripped.start_with?('#')
193
+
194
+ # Skip blank lines
195
+ next if stripped.empty?
196
+
197
+ # Store as action or note based on what we've seen so far
198
+ if current_action.nil?
199
+ current_action = stripped
200
+ else
201
+ # Subsequent lines are notes
202
+ current_note << stripped
203
+ end
204
+ end
205
+
206
+ # Save last action
207
+ results[current_file] = [current_action, current_note] if current_file && current_action
208
+
209
+ results
210
+ end
123
211
  end
124
212
  end
125
213
  end
data/lib/na/hash.rb CHANGED
@@ -1,9 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ ##
4
+ # Extensions to Ruby's Hash class for symbolizing keys and deep freezing values.
5
+ #
6
+ # @example Symbolize all keys in a hash
7
+ # { 'foo' => 1, 'bar' => { 'baz' => 2 } }.symbolize_keys #=> { :foo => 1, :bar => { :baz => 2 } }
3
8
  class ::Hash
4
9
  # Convert all keys in the hash to symbols recursively
5
10
  #
6
11
  # @return [Hash] Hash with symbolized keys
12
+ # @example
13
+ # { 'foo' => 1, 'bar' => { 'baz' => 2 } }.symbolize_keys #=> { :foo => 1, :bar => { :baz => 2 } }
7
14
  def symbolize_keys
8
15
  each_with_object({}) { |(k, v), hsh| hsh[k.to_sym] = v.is_a?(Hash) ? v.symbolize_keys : v }
9
16
  end
@@ -12,6 +19,8 @@ class ::Hash
12
19
  # Freeze all values in a hash
13
20
  #
14
21
  # @return Hash with all values frozen
22
+ # @example
23
+ # { foo: { bar: 'baz' } }.deep_freeze
15
24
  def deep_freeze
16
25
  chilled = {}
17
26
  each do |k, v|
@@ -1,17 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ ##
4
+ # Monkeypatches for GLI CLI framework to support paginated help output.
5
+ #
6
+ # @example Show help for a command
7
+ # GLI::Commands::Help.new.show_help({}, {}, [], $stdout, $stderr)
3
8
  module GLI
9
+ ##
10
+ # Command extensions for GLI CLI framework.
4
11
  module Commands
5
12
  # Help Command Monkeypatch for paginated output
6
13
  class Help < Command
7
14
  # Show help output for GLI commands with paginated output
8
15
  #
9
- # @param global_options [Hash] Global CLI options
16
+ # @param _global_options [Hash] Global CLI options
10
17
  # @param options [Hash] Command-specific options
11
18
  # @param arguments [Array] Command arguments
12
19
  # @param out [IO] Output stream
13
20
  # @param error [IO] Error stream
14
21
  # @return [void]
22
+ # @example
23
+ # GLI::Commands::Help.new.show_help({}, {}, [], $stdout, $stderr)
15
24
  def show_help(_global_options, options, arguments, out, error)
16
25
  NA::Pager.paginate = true
17
26
 
@@ -8,10 +8,17 @@ module NA
8
8
  attr_accessor :verbose, :extension, :include_ext, :na_tag, :command_line, :command, :globals, :global_file,
9
9
  :cwd_is, :cwd, :stdin, :show_cwd_indicator
10
10
 
11
+ # Returns the current theme hash for color and style settings.
12
+ # @return [Hash] The theme settings
11
13
  def theme
12
14
  @theme ||= NA::Theme.load_theme
13
15
  end
14
16
 
17
+ # Print a message to stderr, optionally exit or debug.
18
+ # @param msg [String] The message to print
19
+ # @param exit_code [Integer, Boolean] Exit code or false for no exit
20
+ # @param debug [Boolean] Only print if verbose
21
+ # @return [void]
15
22
  def notify(msg, exit_code: false, debug: false)
16
23
  return if debug && !NA.verbose
17
24
 
@@ -23,6 +30,8 @@ module NA
23
30
  Process.exit exit_code if exit_code
24
31
  end
25
32
 
33
+ # Returns a map of priority levels to numeric values.
34
+ # @return [Hash{String=>Integer}] Priority mapping
26
35
  def priority_map
27
36
  {
28
37
  'h' => 5,
@@ -128,6 +137,11 @@ module NA
128
137
  end
129
138
  end
130
139
 
140
+ # Shift project indices after a given index by a length.
141
+ # @param projects [Array<NA::Project>] Projects to shift
142
+ # @param idx [Integer] Index after which to shift
143
+ # @param length [Integer] Amount to shift
144
+ # @return [Array<NA::Project>] Shifted projects
131
145
  def shift_index_after(projects, idx, length = 1)
132
146
  projects.map do |proj|
133
147
  proj.line = proj.line - length if proj.line > idx
@@ -155,8 +169,9 @@ module NA
155
169
  # @param done [Boolean] Include done actions
156
170
  # @param project [String, nil] Project name
157
171
  # @param search_note [Boolean] Search notes
172
+ # @param target_line [Integer] Specific line number to target
158
173
  # @return [Array] Projects and actions
159
- def find_actions(target, search, tagged = nil, all: false, done: false, project: nil, search_note: true)
174
+ def find_actions(target, search, tagged = nil, all: false, done: false, project: nil, search_note: true, target_line: nil)
160
175
  todo = NA::Todo.new({ search: search,
161
176
  search_note: search_note,
162
177
  require_na: false,
@@ -173,7 +188,17 @@ module NA
173
188
 
174
189
  return [todo.projects, todo.actions] if todo.actions.count == 1 || all
175
190
 
176
- options = todo.actions.map { |action| "#{action.line} % #{action.parent.join('/')} : #{action.action}" }
191
+ # If target_line is specified, find the action at that specific line
192
+ if target_line
193
+ matching_action = todo.actions.find { |a| a.line == target_line }
194
+ return [todo.projects, NA::Actions.new([matching_action])] if matching_action
195
+
196
+ NA.notify("#{NA.theme[:error]}No action found at line #{target_line}", exit_code: 1)
197
+ return [todo.projects, NA::Actions.new]
198
+
199
+ end
200
+
201
+ options = todo.actions.map { |action| "#{action.file} : #{action.action}" }
177
202
  res = choose_from(options, prompt: 'Make a selection: ', multiple: true, sorted: true)
178
203
 
179
204
  unless res&.length&.positive?
@@ -183,9 +208,14 @@ module NA
183
208
 
184
209
  selected = NA::Actions.new
185
210
  res.each do |result|
186
- idx = result.match(/^(\d+)(?= % )/)[1]
187
- action = todo.actions.select { |a| a.line == idx.to_i }.first
188
- selected.push(action)
211
+ # Extract file:line from result (e.g., "./todo.taskpaper:21 : action text")
212
+ match = result.match(/^(.+?):(\d+) : /)
213
+ next unless match
214
+
215
+ file_path = match[1]
216
+ line_num = match[2].to_i
217
+ action = todo.actions.select { |a| a.file_path == file_path && a.file_line == line_num }.first
218
+ selected.push(action) if action
189
219
  end
190
220
  [todo.projects, selected]
191
221
  end
@@ -194,9 +224,8 @@ module NA
194
224
  #
195
225
  # @param target [String] Path to the todo file
196
226
  # @param project [String] Project name
197
- # @param projects [Array<NA::Project>] Existing projects
198
227
  # @return [NA::Project] The new project
199
- def insert_project(target, project, _projects)
228
+ def insert_project(target, project)
200
229
  path = project.split(%r{[:/]})
201
230
  todo = NA::Todo.new(file_path: target)
202
231
  built = []
@@ -299,6 +328,9 @@ module NA
299
328
  remove_tag: [],
300
329
  replace: nil,
301
330
  tagged: nil)
331
+ # Expand target to absolute path to avoid path resolution issues
332
+ target = File.expand_path(target) unless Pathname.new(target).absolute?
333
+
302
334
  projects = find_projects(target)
303
335
  affected_actions = []
304
336
 
@@ -323,11 +355,14 @@ module NA
323
355
  contents = target.read_file.split("\n")
324
356
 
325
357
  if add.is_a?(Action)
358
+ # NOTE: Edit is handled in the update command before calling update_action
359
+ # So we don't need to handle it here - the action is already edited
360
+
326
361
  add_tag ||= []
327
362
  add.process(priority: priority, finish: finish, add_tag: add_tag, remove_tag: remove_tag)
328
363
 
329
364
  # Remove the original action and its notes
330
- action_line = add.line
365
+ action_line = add.file_line
331
366
  note_lines = add.note.is_a?(Array) ? add.note.count : 0
332
367
  contents.slice!(action_line, note_lines + 1)
333
368
 
@@ -376,27 +411,36 @@ module NA
376
411
  changes << "moved to #{target_proj.project}" if move && target_proj
377
412
  affected_actions << { action: add, desc: changes.join(', ') }
378
413
  else
414
+ # Check if search is actually target_line
415
+ target_line = search.is_a?(Hash) && search[:target_line] ? search[:target_line] : nil
379
416
  _, actions = find_actions(target, search, tagged, done: done, all: all, project: project,
380
- search_note: search_note)
417
+ search_note: search_note, target_line: target_line)
381
418
 
382
419
  return if actions.nil?
383
420
 
384
- actions.sort_by(&:line).reverse.each do |action|
385
- contents.slice!(action.line, action.note.count + 1)
421
+ # Handle edit (single or multi-action)
422
+ if edit
423
+ editor_content = Editor.format_multi_action_input(actions)
424
+ edited_content = Editor.fork_editor(editor_content)
425
+ edited_actions = Editor.parse_multi_action_output(edited_content)
426
+
427
+ # Map edited content back to actions
428
+ actions.each do |action|
429
+ # Use file_path:file_line as the key
430
+ key = "#{action.file_path}:#{action.file_line}"
431
+ action.action, action.note = edited_actions[key] if edited_actions[key]
432
+ end
433
+ end
434
+
435
+ actions.sort_by(&:file_line).reverse.each do |action|
436
+ contents.slice!(action.file_line, action.note.count + 1)
386
437
  if delete
387
438
  # Track deletion before skipping re-insert
388
439
  affected_actions << { action: action, desc: 'deleted' }
389
440
  next
390
441
  end
391
442
 
392
- projects = shift_index_after(projects, action.line, action.note.count + 1)
393
-
394
- if edit
395
- editor_content = "#{action.action}\n#{action.note.join("\n")}"
396
- new_action, new_note = Editor.format_input(Editor.fork_editor(editor_content))
397
- action.action = new_action
398
- action.note = new_note
399
- end
443
+ projects = shift_index_after(projects, action.file_line, action.note.count + 1)
400
444
 
401
445
  # If replace is defined, use search to search and replace text in action
402
446
  action.action.sub!(Regexp.new(Regexp.escape(search), Regexp::IGNORECASE), replace) if replace
@@ -602,6 +646,19 @@ module NA
602
646
  end
603
647
  end
604
648
 
649
+ # Find files matching criteria and containing actions.
650
+ # @param options [Hash] Options for file search
651
+ # @option options [Integer] :depth Search depth
652
+ # @option options [Boolean] :done Include done actions
653
+ # @option options [String] :file_path File path
654
+ # @option options [Boolean] :negate Negate search
655
+ # @option options [Boolean] :hidden Include hidden files
656
+ # @option options [String] :project Project name
657
+ # @option options [String] :query Query string
658
+ # @option options [Boolean] :regex Use regex
659
+ # @option options [String] :search Search string
660
+ # @option options [String] :tag Tag to filter
661
+ # @return [Array<String>] Matching files
605
662
  def find_files_matching(options = {})
606
663
  defaults = {
607
664
  depth: 1,
@@ -690,6 +747,10 @@ module NA
690
747
  end
691
748
  end
692
749
 
750
+ # Find a directory with an exact match from a list.
751
+ # @param dirs [Array<String>] Directories to search
752
+ # @param search [Array<Hash>] Search tokens
753
+ # @return [Array<String>] Matching directories
693
754
  def find_exact_dir(dirs, search)
694
755
  terms = search.filter { |s| !s[:negate] }.map { |t| t[:token] }.join(' ')
695
756
  out = dirs
@@ -878,6 +939,12 @@ module NA
878
939
  File.open(file, 'w') { |f| f.puts dirs.join("\n") }
879
940
  end
880
941
 
942
+ # List projects in a todo file or matching query.
943
+ # @param query [Array] Query tokens
944
+ # @param file_path [String, nil] File path
945
+ # @param depth [Integer] Search depth
946
+ # @param paths [Boolean] Show full paths
947
+ # @return [void]
881
948
  def list_projects(query: [], file_path: nil, depth: 1, paths: true)
882
949
  files = if NA.global_file
883
950
  [NA.global_file]
@@ -906,6 +973,9 @@ module NA
906
973
  end
907
974
  end
908
975
 
976
+ # List todo files matching a query.
977
+ # @param query [Array] Query tokens
978
+ # @return [void]
909
979
  def list_todos(query: [])
910
980
  dirs = if query
911
981
  match_working_dir(query, distance: 2, require_last: false)
@@ -922,6 +992,10 @@ module NA
922
992
  puts NA::Color.template(dirs.join("\n"))
923
993
  end
924
994
 
995
+ # Save a search definition to the database.
996
+ # @param title [String] The search title
997
+ # @param search [String] The search string
998
+ # @return [void]
925
999
  def save_search(title, search)
926
1000
  file = database_path(file: 'saved_searches.yml')
927
1001
  searches = load_searches
data/lib/na/project.rb CHANGED
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NA
4
+ # Represents a project section in a todo file, with indentation and line tracking.
5
+ #
6
+ # @example Create a new project
7
+ # project = NA::Project.new('Inbox', 0, 1, 5)
4
8
  class Project < Hash
5
9
  attr_accessor :project, :indent, :line, :last_line
6
10
 
@@ -11,6 +15,8 @@ module NA
11
15
  # @param line [Integer] Starting line number
12
16
  # @param last_line [Integer] Ending line number
13
17
  # @return [void]
18
+ # @example
19
+ # project = NA::Project.new('Inbox', 0, 1, 5)
14
20
  def initialize(project, indent = 0, line = 0, last_line = 0)
15
21
  super()
16
22
  @project = project
@@ -22,6 +28,8 @@ module NA
22
28
  # String representation of the project
23
29
  #
24
30
  # @return [String]
31
+ # @example
32
+ # project.to_s #=> "{ project: 'Inbox', ... }"
25
33
  def to_s
26
34
  { project: @project, indent: @indent, line: @line, last_line: @last_line }.to_s
27
35
  end
data/lib/na/string.rb CHANGED
@@ -1,15 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Special handling for nil strings
4
+ class ::NilClass
5
+ # Always returns an empty string for nil.
6
+ # @return [String]
7
+ def highlight_filename
8
+ ''
9
+ end
10
+
11
+ # Always returns an empty string for nil.
12
+ # @param max [Integer] Maximum allowed length
13
+ # @return [String]
14
+ def trunc_middle(max)
15
+ ''
16
+ end
17
+ end
18
+
19
+ # Matches day names (e.g., mon, tue, wednesday)
20
+ # @return [Regexp]
3
21
  REGEX_DAY = /^(mon|tue|wed|thur?|fri|sat|sun)(\w+(day)?)?$/i.freeze
22
+
23
+ # Matches clock times (e.g., 12:30 pm, midnight)
24
+ # @return [String]
4
25
  REGEX_CLOCK = '(?:\d{1,2}+(?::\d{1,2}+)?(?: *(?:am|pm))?|midnight|noon)'
26
+
27
+ # Matches time strings using REGEX_CLOCK
28
+ # @return [Regexp]
5
29
  REGEX_TIME = /^#{REGEX_CLOCK}$/i.freeze
6
30
 
7
31
  # String helpers
8
32
  class ::String
9
33
  # Insert a comment character at the start of every line
10
34
  # @param char [String] The character to insert (default #)
11
- def comment(_char = '#')
12
- split("\n").map { |l| "# #{l}" }.join("\n")
35
+ def comment(char = '#')
36
+ split("\n").map { |l| "#{char} #{l}" }.join("\n")
13
37
  end
14
38
 
15
39
  # Tests if object is nil or empty
@@ -25,6 +49,10 @@ class ::String
25
49
  line =~ /^#/ || line.strip.empty?
26
50
  end
27
51
 
52
+ # Returns the contents of the file, or raises if missing.
53
+ # Handles directories and NA extension.
54
+ # @return [String] Contents of the file
55
+ # @raise [RuntimeError] if the file does not exist
28
56
  def read_file
29
57
  file = File.expand_path(self)
30
58
  raise "Missing file #{file}" unless File.exist?(file)
@@ -64,11 +92,15 @@ class ::String
64
92
  !action? && self =~ /:( +@\S+(\([^)]*\))?)*$/
65
93
  end
66
94
 
95
+ # Returns the project name if matched, otherwise nil.
96
+ # @return [String, nil]
67
97
  def project
68
98
  m = match(/^([ \t]*)([^\-][^@:]*?): *(@\S+ *)*$/)
69
99
  m ? m[2] : nil
70
100
  end
71
101
 
102
+ # Returns the action text with leading dash and whitespace removed.
103
+ # @return [String]
72
104
  def action
73
105
  sub(/^[ \t]*- /, '')
74
106
  end
@@ -84,6 +116,8 @@ class ::String
84
116
  # Colorize the dirname and filename of a path
85
117
  # @return [String] Colorized string
86
118
  def highlight_filename
119
+ return '' if nil?
120
+
87
121
  dir = File.dirname(self).shorten_path.trunc_middle(TTY::Screen.columns / 3)
88
122
  file = NA.include_ext ? File.basename(self) : File.basename(self, ".#{NA.extension}")
89
123
  "#{NA.theme[:dirname]}#{dir}/#{NA.theme[:filename]}#{file}{x}"
@@ -115,13 +149,17 @@ class ::String
115
149
  # @param color [String] The highlight color template
116
150
  # @param last_color [String] Color to restore after highlight
117
151
  def highlight_search(regexes, color: NA.theme[:search_highlight], last_color: NA.theme[:action])
152
+ # Skip if string already contains ANSI codes - applying regex to colored text
153
+ # will break escape sequences (e.g., searching for "3" will match "3" in "38;2;236;204;135m")
154
+ return self if include?("\e")
155
+
156
+ # Original simple approach for strings without ANSI codes
118
157
  string = dup
119
158
  color = NA::Color.template(color.dup)
120
159
  regexes.each do |rx|
121
160
  next if rx.nil?
122
161
 
123
162
  rx = Regexp.new(rx, Regexp::IGNORECASE) if rx.is_a?(String)
124
-
125
163
  string.gsub!(rx) do
126
164
  m = Regexp.last_match
127
165
  last = m.pre_match.last_color
@@ -135,14 +173,15 @@ class ::String
135
173
  # @param max [Integer] Maximum allowed length of the string
136
174
  # @return [String] Truncated string with middle replaced if necessary
137
175
  def trunc_middle(max)
138
- return '' if self.nil?
139
- return self unless length > max
176
+ return '' if nil?
177
+
178
+ return self unless length > max
140
179
 
141
- half = (max / 2).floor - 3
142
- chars = chars
143
- pre = chars.slice(0, half)
144
- post = chars.reverse.slice(0, half).reverse
145
- "#{pre.join}[...]#{post.join}"
180
+ half = (max / 2).floor - 3
181
+ cs = chars
182
+ pre = cs.slice(0, half)
183
+ post = cs.reverse.slice(0, half).reverse
184
+ "#{pre.join}[...]#{post.join}"
146
185
  end
147
186
 
148
187
  # Wrap the string to a given width, indenting each line and preserving tag formatting.
@@ -155,9 +194,9 @@ class ::String
155
194
  output = []
156
195
  line = []
157
196
  length = 0
158
- gsub!(/(@\S+)\((.*?)\)/) { "#{Regexp.last_match(1)}(#{Regexp.last_match(2).gsub(/ /, '†')})" }
197
+ text = gsub(/(@\S+)\((.*?)\)/) { "#{Regexp.last_match(1)}(#{Regexp.last_match(2).gsub(/ /, '†')})" }
159
198
 
160
- split(' ').each do |word|
199
+ text.split.each do |word|
161
200
  uncolored = NA::Color.uncolor(word)
162
201
  if (length + uncolored.length + 1) <= width
163
202
  line << word