pyDiffTools 0.1.48__tar.gz → 0.1.49__tar.gz

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.
Files changed (119) hide show
  1. {pydifftools-0.1.48/pyDiffTools.egg-info → pydifftools-0.1.49}/PKG-INFO +1 -1
  2. {pydifftools-0.1.48 → pydifftools-0.1.49/pyDiffTools.egg-info}/PKG-INFO +1 -1
  3. {pydifftools-0.1.48 → pydifftools-0.1.49}/pyDiffTools.egg-info/SOURCES.txt +3 -0
  4. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/browser_lifecycle.py +31 -0
  5. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/comment_toggle.js +1 -1
  6. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/comments.css +5 -4
  7. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/continuous.py +37 -5
  8. pydifftools-0.1.49/pydifftools/flowchart/source_jump.js +158 -0
  9. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/flowchart/watch_graph.py +53 -3
  10. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/flowchart/wgrph_view.js +23 -0
  11. pydifftools-0.1.49/pydifftools/source_jump.py +128 -0
  12. {pydifftools-0.1.48 → pydifftools-0.1.49}/pyproject.toml +2 -1
  13. pydifftools-0.1.49/tests/test_browser_lifecycle.py +115 -0
  14. pydifftools-0.1.49/tests/test_source_jump.py +85 -0
  15. pydifftools-0.1.48/tests/test_browser_lifecycle.py +0 -30
  16. {pydifftools-0.1.48 → pydifftools-0.1.49}/LICENSE.md +0 -0
  17. {pydifftools-0.1.48 → pydifftools-0.1.49}/MANIFEST.in +0 -0
  18. {pydifftools-0.1.48 → pydifftools-0.1.49}/README.rst +0 -0
  19. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/_quarto.yml +0 -0
  20. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/__version__.txt +0 -0
  21. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/1a724af72b16f5a9e607e12b1c721645/base.ipynb +0 -0
  22. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/1b28fc9daac9081847e5161b2c546f8a/base.ipynb +0 -0
  23. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/231f64eee282fa225d1104935cf80a24/base.ipynb +0 -0
  24. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/26e56f6b0ff54851a45145157f2f0dc4/base.ipynb +0 -0
  25. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/311fabd7029ffd050d056e2f316eb50f/base.ipynb +0 -0
  26. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/57da2021e5b156ac3adf01398201c723/base.ipynb +0 -0
  27. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/62b24ea7da75011d92b0f8924faa208d/base.ipynb +0 -0
  28. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/7f1b20d69d889514ab5d1cc92e3cb14f/base.ipynb +0 -0
  29. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/86f74c8c54a87ff892d9b15dd714e8f0/base.ipynb +0 -0
  30. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/88893b4234eac2945d9d6cb2e277f186/base.ipynb +0 -0
  31. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/9a40046ada6f582ee34af00fbdbfb417/base.ipynb +0 -0
  32. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/a1bf4d270d0641ff41faf1d7cce3439a/base.ipynb +0 -0
  33. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/a3789f7d9585a781f2a1c60ce95ff10d/base.ipynb +0 -0
  34. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/be46ecba858d39ad5f0c46902ddf1c02/base.ipynb +0 -0
  35. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/d0cbc57a12f2ccce710a5afd04cc05e7/base.ipynb +0 -0
  36. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/d3e12d320b14228f701231ae32ddd7dd/base.ipynb +0 -0
  37. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/eb434f61555438d020a6970a5dbf9ee8/base.ipynb +0 -0
  38. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/f6b0e73aa7fa029134665d4dde57e096/base.ipynb +0 -0
  39. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/f719a6e4ff09873cb0ffb06ec9d232f9/base.ipynb +0 -0
  40. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/executed/feeee244a7ce3d60e1a227eb604df823/base.ipynb +0 -0
  41. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/.jupyter_cache/global.db +0 -0
  42. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/example.qmd +0 -0
  43. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/example.tex +0 -0
  44. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/index.qmd +0 -0
  45. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/subproject1/.jupyter_cache/__version__.txt +0 -0
  46. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/subproject1/.jupyter_cache/executed/ca90e4df5f4f0583df6554156a68dc7f/base.ipynb +0 -0
  47. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/subproject1/.jupyter_cache/global.db +0 -0
  48. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/subproject1/end_vs_he_sketch.jpg +0 -0
  49. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/subproject1/independent.qmd +0 -0
  50. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/subproject1/index.qmd +0 -0
  51. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/subproject1/tasks.qmd +0 -0
  52. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/subproject1/test_include.qmd +0 -0
  53. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/subproject1/tryforerror.qmd +0 -0
  54. {pydifftools-0.1.48 → pydifftools-0.1.49}/example_notebook/project1/test_include.qmd +0 -0
  55. {pydifftools-0.1.48 → pydifftools-0.1.49}/pyDiffTools.egg-info/dependency_links.txt +0 -0
  56. {pydifftools-0.1.48 → pydifftools-0.1.49}/pyDiffTools.egg-info/entry_points.txt +0 -0
  57. {pydifftools-0.1.48 → pydifftools-0.1.49}/pyDiffTools.egg-info/requires.txt +0 -0
  58. {pydifftools-0.1.48 → pydifftools-0.1.49}/pyDiffTools.egg-info/top_level.txt +0 -0
  59. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/__init__.py +0 -0
  60. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/bibliography.py +0 -0
  61. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/check_numbers.py +0 -0
  62. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/citation_dialog.py +0 -0
  63. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/command_line.py +0 -0
  64. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/command_registry.py +0 -0
  65. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/comment_functions.py +0 -0
  66. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/comment_migration.py +0 -0
  67. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/comment_tags.lua +0 -0
  68. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/comment_tags_margin.lua +0 -0
  69. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/comment_tags_no_comments.lua +0 -0
  70. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/comments_author_colors.css +0 -0
  71. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/copy_files.py +0 -0
  72. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/diff-doc.js +0 -0
  73. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/doc_contents.py +0 -0
  74. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/flowchart/__init__.py +0 -0
  75. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/flowchart/comparison.py +0 -0
  76. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/flowchart/dot_to_yaml.py +0 -0
  77. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/flowchart/graph.py +0 -0
  78. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/flowchart/svg-pan-zoom.min.js +0 -0
  79. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/forward_search.py +0 -0
  80. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/git_gd.py +0 -0
  81. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/git_gd_history.py +0 -0
  82. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/git_gd_image.py +0 -0
  83. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/git_gd_qt.py +0 -0
  84. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/html_comments.py +0 -0
  85. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/html_uncomments.py +0 -0
  86. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/log_example.py +0 -0
  87. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/match_spaces.py +0 -0
  88. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/notebook/__init__.py +0 -0
  89. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/notebook/display.py +0 -0
  90. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/notebook/fast_build.py +0 -0
  91. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/notebook/tex_to_qmd.py +0 -0
  92. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/onewordify.py +0 -0
  93. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/onewordify_undo.py +0 -0
  94. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/outline.py +0 -0
  95. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/rearrange_tex.py +0 -0
  96. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/searchacro.py +0 -0
  97. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/separate_comments.py +0 -0
  98. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/split_conflict.py +0 -0
  99. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/unseparate_comments.py +0 -0
  100. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/update_check.py +0 -0
  101. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/wrap_sentences.py +0 -0
  102. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/xml2xlsx.vbs +0 -0
  103. {pydifftools-0.1.48 → pydifftools-0.1.49}/pydifftools/zotero.py +0 -0
  104. {pydifftools-0.1.48 → pydifftools-0.1.49}/setup.cfg +0 -0
  105. {pydifftools-0.1.48 → pydifftools-0.1.49}/tests/test_bibliography.py +0 -0
  106. {pydifftools-0.1.48 → pydifftools-0.1.49}/tests/test_check_single_use.py +0 -0
  107. {pydifftools-0.1.48 → pydifftools-0.1.49}/tests/test_command_line_help.py +0 -0
  108. {pydifftools-0.1.48 → pydifftools-0.1.49}/tests/test_comment_migration.py +0 -0
  109. {pydifftools-0.1.48 → pydifftools-0.1.49}/tests/test_continuous_shutdown.py +0 -0
  110. {pydifftools-0.1.48 → pydifftools-0.1.49}/tests/test_forward_search.py +0 -0
  111. {pydifftools-0.1.48 → pydifftools-0.1.49}/tests/test_git_gd_history.py +0 -0
  112. {pydifftools-0.1.48 → pydifftools-0.1.49}/tests/test_git_gd_image.py +0 -0
  113. {pydifftools-0.1.48 → pydifftools-0.1.49}/tests/test_git_gd_qt.py +0 -0
  114. {pydifftools-0.1.48 → pydifftools-0.1.49}/tests/test_rrng.py +0 -0
  115. {pydifftools-0.1.48 → pydifftools-0.1.49}/tests/test_tex_to_qmd.py +0 -0
  116. {pydifftools-0.1.48 → pydifftools-0.1.49}/tests/test_update_check.py +0 -0
  117. {pydifftools-0.1.48 → pydifftools-0.1.49}/tests/test_wrap_check.py +0 -0
  118. {pydifftools-0.1.48 → pydifftools-0.1.49}/tests/test_wrapping.py +0 -0
  119. {pydifftools-0.1.48 → pydifftools-0.1.49}/tests/test_zotero.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.48
3
+ Version: 0.1.49
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyDiffTools
3
- Version: 0.1.48
3
+ Version: 0.1.49
4
4
  Summary: Diff tools
5
5
  Author: J M Franck
6
6
  License: Copyright (c) 2015, jmfranck
@@ -78,6 +78,7 @@ pydifftools/outline.py
78
78
  pydifftools/rearrange_tex.py
79
79
  pydifftools/searchacro.py
80
80
  pydifftools/separate_comments.py
81
+ pydifftools/source_jump.py
81
82
  pydifftools/split_conflict.py
82
83
  pydifftools/unseparate_comments.py
83
84
  pydifftools/update_check.py
@@ -88,6 +89,7 @@ pydifftools/flowchart/__init__.py
88
89
  pydifftools/flowchart/comparison.py
89
90
  pydifftools/flowchart/dot_to_yaml.py
90
91
  pydifftools/flowchart/graph.py
92
+ pydifftools/flowchart/source_jump.js
91
93
  pydifftools/flowchart/svg-pan-zoom.min.js
92
94
  pydifftools/flowchart/watch_graph.py
93
95
  pydifftools/flowchart/wgrph_view.js
@@ -106,6 +108,7 @@ tests/test_git_gd_history.py
106
108
  tests/test_git_gd_image.py
107
109
  tests/test_git_gd_qt.py
108
110
  tests/test_rrng.py
111
+ tests/test_source_jump.py
109
112
  tests/test_tex_to_qmd.py
110
113
  tests/test_update_check.py
111
114
  tests/test_wrap_check.py
@@ -1,12 +1,43 @@
1
1
  import os
2
+ import re
2
3
  import shutil
3
4
  import subprocess
5
+ import sys
4
6
 
5
7
  # callbacks that run before any dialog needs the user; cpb registers one
6
8
  # that closes its live preview, so issues are dealt with before it reopens
7
9
  dialog_callbacks = []
8
10
 
9
11
 
12
+ def launch_chrome(webdriver, **kwargs):
13
+ """Start Chrome, retrying version mismatches with a warning."""
14
+ from selenium.common.exceptions import SessionNotCreatedException
15
+ from selenium.webdriver.chrome.service import Service
16
+
17
+ try:
18
+ return webdriver.Chrome(**kwargs)
19
+ except SessionNotCreatedException as exc:
20
+ # {{{ Retry only ChromeDriver's explicit browser-version rejection
21
+ details = str(exc)
22
+ driver_match = re.search(
23
+ r"ChromeDriver only supports Chrome version (\d+)", details
24
+ )
25
+ browser_match = re.search(
26
+ r"Current browser version is (\d+)", details
27
+ )
28
+ if driver_match is None or browser_match is None:
29
+ raise
30
+ print(
31
+ "pydifft: warning: ChromeDriver "
32
+ f"{driver_match.group(1)} and Chrome {browser_match.group(1)} "
33
+ "do not match; retrying with the driver version check disabled.",
34
+ file=sys.stderr,
35
+ )
36
+ kwargs["service"] = Service(service_args=["--disable-build-check"])
37
+ return webdriver.Chrome(**kwargs)
38
+ # }}}
39
+
40
+
10
41
  def prepare_for_dialog():
11
42
  """Let watch commands close their preview before a dialog appears."""
12
43
  for callback in list(dialog_callbacks):
@@ -34,7 +34,7 @@
34
34
  }
35
35
 
36
36
  function positionComments() {
37
- const useMobileFlow = window.matchMedia("(max-width: 900px)").matches;
37
+ const useMobileFlow = window.matchMedia("(max-width: 600px)").matches;
38
38
  // Inline comment bubbles are absolutely positioned relative to a zero-width
39
39
  // pin. We nudge overlapping bubbles so adjacent <comment> tags visibly
40
40
  // separate, and we raise the bubbles so the pointer aims at the source point.
@@ -4,9 +4,10 @@ html {
4
4
  }
5
5
  body {
6
6
  margin: 0 auto;
7
- max-width: 36em;
8
- padding-left: 50px;
9
- padding-right: 50px;
7
+ /* Keep 75% of the previous side margins and give the rest to the text. */
8
+ max-width: calc(27em + 25%);
9
+ padding-left: 37.5px;
10
+ padding-right: 37.5px;
10
11
  padding-top: 50px;
11
12
  padding-bottom: 50px;
12
13
  hyphens: auto;
@@ -311,7 +312,7 @@ span.comment-pin > span.comment-left::after {
311
312
  }
312
313
 
313
314
  /* ---------- Mobile fallback ---------- */
314
- @media (max-width: 900px) {
315
+ @media (max-width: 600px) {
315
316
  div.comment-right,
316
317
  div.comment-left,
317
318
  span.comment-pin > span.comment-right,
@@ -25,6 +25,7 @@ from .browser_lifecycle import (
25
25
  close_browser_window,
26
26
  dialog_callbacks,
27
27
  forward_search_in_browser,
28
+ launch_chrome,
28
29
  prepare_for_dialog,
29
30
  )
30
31
  from .forward_search import (
@@ -34,6 +35,7 @@ from .forward_search import (
34
35
  drain_forward_search_queue,
35
36
  serve_forward_search,
36
37
  )
38
+ from .source_jump import SourceJumpServer
37
39
 
38
40
  FORWARD_SEARCH_PORT = CPB_FORWARD_SEARCH_PORT
39
41
  POLL_INTERVAL_SECONDS = 0.1
@@ -802,7 +804,7 @@ def run_pandoc(
802
804
  return
803
805
 
804
806
 
805
- def append_autorefresh(html_file):
807
+ def append_autorefresh(html_file, source_jump_url=None):
806
808
  with open(html_file, "r", encoding="utf-8") as fp:
807
809
  all_data = fp.read()
808
810
  all_data = all_data.replace(
@@ -867,6 +869,29 @@ position
867
869
  </head>
868
870
  """,
869
871
  )
872
+ if source_jump_url:
873
+ all_data = re.sub(
874
+ r'\s*<script id="pydifft-source-jump-config">.*?</script>'
875
+ r'\s*<script id="pydifft-source-jump">.*?</script>',
876
+ "",
877
+ all_data,
878
+ flags=re.DOTALL,
879
+ )
880
+ source_jump_js = (
881
+ Path(__file__).parent / "flowchart" / "source_jump.js"
882
+ ).read_text(encoding="utf-8")
883
+ source_jump_scripts = (
884
+ '<script id="pydifft-source-jump-config">'
885
+ "window.pydifftSourceJumpEndpoint = "
886
+ + json.dumps(source_jump_url)
887
+ + ";</script>"
888
+ '<script id="pydifft-source-jump">'
889
+ + source_jump_js
890
+ + "</script>"
891
+ )
892
+ all_data = all_data.replace(
893
+ "</head>", source_jump_scripts + "</head>", 1
894
+ )
870
895
  with open(html_file, "w", encoding="utf-8") as fp:
871
896
  fp.write(all_data)
872
897
 
@@ -939,6 +964,7 @@ def cpb(filename, comments_to_margin=False, no_comments=False, wrapnumber=79):
939
964
  observer = None
940
965
  observer_started = False
941
966
  socket_thread_started = False
967
+ source_jump_server = None
942
968
 
943
969
  try:
944
970
  # Bind and serve before any build or browser work. This makes the fixed
@@ -968,14 +994,16 @@ def cpb(filename, comments_to_margin=False, no_comments=False, wrapnumber=79):
968
994
  comment_filter_session=comment_filter_session,
969
995
  wrapnumber=wrapnumber,
970
996
  )
971
- append_autorefresh(html_file)
997
+ source_jump_server = SourceJumpServer(source_path)
998
+ source_jump_server.start()
999
+ append_autorefresh(html_file, source_jump_server.url)
972
1000
 
973
1001
  # Selenium is deliberately initialized once, after the first
974
1002
  # successful build. Nothing in recovery constructs a browser.
975
1003
  from selenium import webdriver
976
1004
  from selenium.common.exceptions import WebDriverException
977
1005
 
978
- chrome = webdriver.Chrome()
1006
+ chrome = launch_chrome(webdriver)
979
1007
  observer = Observer()
980
1008
  change_queue = queue.Queue()
981
1009
  event_handler = Handler(filename, change_queue)
@@ -1113,7 +1141,9 @@ def cpb(filename, comments_to_margin=False, no_comments=False, wrapnumber=79):
1113
1141
  comment_filter_session=comment_filter_session,
1114
1142
  wrapnumber=wrapnumber,
1115
1143
  )
1116
- append_autorefresh(html_file)
1144
+ append_autorefresh(
1145
+ html_file, source_jump_server.url
1146
+ )
1117
1147
  except Exception as exc:
1118
1148
  exception_filename = getattr(exc, "filename", None)
1119
1149
  missing_source = isinstance(
@@ -1155,7 +1185,7 @@ def cpb(filename, comments_to_margin=False, no_comments=False, wrapnumber=79):
1155
1185
  break
1156
1186
  if chrome is None:
1157
1187
  # {{{ reopen the preview a dialog closed
1158
- chrome = webdriver.Chrome()
1188
+ chrome = launch_chrome(webdriver)
1159
1189
  chrome.get(
1160
1190
  "file://" + os.path.abspath(html_file)
1161
1191
  )
@@ -1167,6 +1197,8 @@ def cpb(filename, comments_to_margin=False, no_comments=False, wrapnumber=79):
1167
1197
  finally:
1168
1198
  stop_event.set()
1169
1199
  forward_search_server.close()
1200
+ if source_jump_server is not None:
1201
+ source_jump_server.stop()
1170
1202
  if observer_started:
1171
1203
  observer.stop()
1172
1204
  observer.join()
@@ -0,0 +1,158 @@
1
+ // Add a small local-only source jump action to cpb and flowchart previews.
2
+ (function () {
3
+ const endpoint = window.pydifftSourceJumpEndpoint;
4
+ if (!endpoint) {
5
+ return;
6
+ }
7
+ const excludedSourceElements =
8
+ '.math,.MathJax,mjx-container,.katex,math,' +
9
+ 'script[type^="math/tex"],' +
10
+ 'a[href*="#fig:"],a[href*="#fig-"],a[href*="#fig%3A"]';
11
+
12
+ const menu = document.createElement('div');
13
+ menu.id = 'pydifft-source-jump-menu';
14
+ menu.style.cssText =
15
+ 'display:none;position:fixed;z-index:2147483647;background:#fff;' +
16
+ 'border:1px solid #777;border-radius:3px;padding:3px;' +
17
+ 'box-shadow:0 2px 6px #555;font:14px sans-serif;';
18
+ const button = document.createElement('button');
19
+ button.type = 'button';
20
+ button.textContent = 'Jump to source';
21
+ button.style.cssText =
22
+ 'border:0;background:transparent;padding:6px 10px;cursor:pointer;' +
23
+ 'font:inherit;';
24
+ menu.appendChild(button);
25
+ document.addEventListener('DOMContentLoaded', function () {
26
+ document.body.appendChild(menu);
27
+ });
28
+
29
+ let phrase = '';
30
+
31
+ function hideMenu() {
32
+ menu.style.display = 'none';
33
+ }
34
+
35
+ function showMenu(event, text) {
36
+ phrase = (text || '').replace(/\s+/g, ' ').trim();
37
+ if (!phrase) {
38
+ hideMenu();
39
+ return;
40
+ }
41
+ if (menu.parentNode !== document.body && document.body) {
42
+ document.body.appendChild(menu);
43
+ }
44
+ event.preventDefault();
45
+ menu.style.left = Math.min(event.clientX, window.innerWidth - 170) + 'px';
46
+ menu.style.top = Math.min(event.clientY, window.innerHeight - 42) + 'px';
47
+ menu.style.display = 'block';
48
+ }
49
+
50
+ function sentenceAtPoint(event) {
51
+ let range = null;
52
+ if (document.caretRangeFromPoint) {
53
+ range = document.caretRangeFromPoint(event.clientX, event.clientY);
54
+ } else if (document.caretPositionFromPoint) {
55
+ const position = document.caretPositionFromPoint(
56
+ event.clientX,
57
+ event.clientY
58
+ );
59
+ if (position) {
60
+ range = document.createRange();
61
+ range.setStart(position.offsetNode, position.offset);
62
+ }
63
+ }
64
+ const target = event.target.nodeType === Node.TEXT_NODE
65
+ ? event.target.parentElement
66
+ : event.target;
67
+ const targetElement = target && target.closest ? target : document.body;
68
+ if (targetElement.closest(excludedSourceElements)) {
69
+ return '';
70
+ }
71
+ const block = targetElement.closest(
72
+ 'p,li,blockquote,h1,h2,h3,h4,h5,h6,dt,dd,td,th'
73
+ ) || targetElement;
74
+ // Drop rendered math and cross-reference links so the phrase resembles
75
+ // the searchable Markdown source, where mfs removes those constructs.
76
+ let text = '';
77
+ let clickedOffset = 0;
78
+ let found = false;
79
+ function collectText(node) {
80
+ if (node.nodeType === Node.TEXT_NODE) {
81
+ if (range && node === range.startContainer) {
82
+ clickedOffset = text.length + range.startOffset;
83
+ found = true;
84
+ }
85
+ text += node.textContent;
86
+ return;
87
+ }
88
+ if (
89
+ node.nodeType === Node.ELEMENT_NODE &&
90
+ node !== block &&
91
+ node.matches(excludedSourceElements)
92
+ ) {
93
+ text += ' ';
94
+ return;
95
+ }
96
+ node.childNodes.forEach(collectText);
97
+ }
98
+ collectText(block);
99
+ const rawText = text;
100
+ text = rawText.replace(/\s+/g, ' ').trim();
101
+ if (!text) {
102
+ return '';
103
+ }
104
+ if (!range || !found) {
105
+ return text;
106
+ }
107
+
108
+ // The raw DOM offset is used to find sentence bounds before whitespace is
109
+ // collapsed. Recompute its visible offset after normalization.
110
+ const normalizedPrefix = rawText
111
+ .slice(0, clickedOffset)
112
+ .replace(/\s+/g, ' ')
113
+ .replace(/^\s+/, '');
114
+ clickedOffset = normalizedPrefix.length;
115
+ const before = text.slice(0, clickedOffset);
116
+ const after = text.slice(clickedOffset);
117
+ const start = Math.max(
118
+ before.lastIndexOf('.'),
119
+ before.lastIndexOf('!'),
120
+ before.lastIndexOf('?')
121
+ ) + 1;
122
+ const endMatch = after.match(/[.!?](?:\s|$)/);
123
+ const end = endMatch ? clickedOffset + endMatch.index + 1 : text.length;
124
+ return text.slice(start, end).trim() || text;
125
+ }
126
+
127
+ button.addEventListener('click', function () {
128
+ hideMenu();
129
+ fetch(endpoint, {
130
+ method: 'POST',
131
+ mode: 'no-cors',
132
+ body: phrase,
133
+ }).catch(function () {
134
+ // The preview may be closing while the local editor request is sent.
135
+ });
136
+ });
137
+ document.addEventListener('click', hideMenu);
138
+ document.addEventListener('keydown', function (event) {
139
+ if (event.key === 'Escape') {
140
+ hideMenu();
141
+ }
142
+ });
143
+ document.addEventListener('contextmenu', function (event) {
144
+ showMenu(event, sentenceAtPoint(event));
145
+ });
146
+ document.addEventListener('pydifft-source-context', function (event) {
147
+ showMenu(
148
+ {
149
+ clientX: event.detail.clientX,
150
+ clientY: event.detail.clientY,
151
+ preventDefault: function () {
152
+ event.preventDefault();
153
+ },
154
+ },
155
+ event.detail.phrase
156
+ );
157
+ });
158
+ })();
@@ -1,5 +1,6 @@
1
1
  import subprocess
2
2
  import html
3
+ import json
3
4
  import sys
4
5
  import time
5
6
  import shutil
@@ -26,7 +27,9 @@ from pydifftools.command_registry import register_command
26
27
  from pydifftools.browser_lifecycle import (
27
28
  browser_window_is_alive,
28
29
  close_browser_window,
30
+ launch_chrome,
29
31
  )
32
+ from pydifftools.source_jump import SourceJumpServer
30
33
  from .comparison import PlanComparison
31
34
  from .graph import EmptyGraphYamlError, endpoint_projects, write_dot_from_yaml
32
35
 
@@ -58,7 +61,7 @@ def _reload_svg(driver, svg_src) -> None:
58
61
 
59
62
  def start_chrome(webdriver, options, preview_url):
60
63
  # Launch Chrome and display the local SVG preview page from the server.
61
- driver = webdriver.Chrome(options=options)
64
+ driver = launch_chrome(webdriver, options=options)
62
65
  driver.get(preview_url)
63
66
  return driver
64
67
 
@@ -117,6 +120,8 @@ def _svg_set_stroke(shape, color, stroke_width=None):
117
120
  )
118
121
 
119
122
 
123
+ # also used by: tests/flowchart/test_watch_reload.py, which verifies
124
+ # query modes
120
125
  def _watch_view_state_from_params(params):
121
126
  # Treat each GET query as the full requested mode so navigation links can
122
127
  # reliably switch back to the overview without depending on prior state.
@@ -138,12 +143,15 @@ def _watch_view_state_from_params(params):
138
143
  return order_by_date, target_task, filter_completed
139
144
 
140
145
 
146
+ # also used by: tests/flowchart/test_watch_reload.py, which verifies
147
+ # preview HTML
141
148
  def _watch_html(
142
149
  svg_url,
143
150
  order_by_date,
144
151
  target_task=None,
145
152
  filter_completed=False,
146
153
  comparison=None,
154
+ source_jump_url=None,
147
155
  ):
148
156
  # {{{ Build links for the other preview views
149
157
  links = []
@@ -213,7 +221,14 @@ def _watch_html(
213
221
  "</p>"
214
222
  "<script src='/svg-pan-zoom.min.js'></script>"
215
223
  "<script src='/wgrph_view.js'></script>"
216
- "</body></html>"
224
+ + (
225
+ "<script>window.pydifftSourceJumpEndpoint = "
226
+ + json.dumps(source_jump_url)
227
+ + ";</script><script src='/source_jump.js'></script>"
228
+ if source_jump_url
229
+ else ""
230
+ )
231
+ + "</body></html>"
217
232
  )
218
233
 
219
234
 
@@ -322,6 +337,17 @@ def build_graph(
322
337
  ET.register_namespace("xlink", xlink_ns)
323
338
  link_marker = "__WGRPH_TASK_LINK__:"
324
339
  for group in svg_root.iter(f"{namespace}g"):
340
+ if group.attrib.get("class") == "node":
341
+ title = next(
342
+ (
343
+ child.text.strip()
344
+ for child in group
345
+ if child.tag == f"{namespace}title" and child.text
346
+ ),
347
+ None,
348
+ )
349
+ if title:
350
+ group.set("data-source-name", title)
325
351
  if "class" not in group.attrib or group.attrib["class"] != "node":
326
352
  continue
327
353
  for index, child in enumerate(list(group)):
@@ -712,6 +738,7 @@ class FlowchartPreviewServer:
712
738
  self.host = host
713
739
  self.httpd = None
714
740
  self.server_thread = None
741
+ self.source_jump_server = None
715
742
  self.base_url = None
716
743
  self.svg_url = None
717
744
 
@@ -736,6 +763,13 @@ class FlowchartPreviewServer:
736
763
  "text/javascript; charset=utf-8",
737
764
  )
738
765
  return
766
+ if parsed.path == "/source_jump.js":
767
+ _send_preview_response(
768
+ self,
769
+ (Path(__file__).parent / parsed.path[1:]).read_bytes(),
770
+ "text/javascript; charset=utf-8",
771
+ )
772
+ return
739
773
 
740
774
  if parsed.path != "/" and parsed.path != "/index.html":
741
775
  self.send_error(404)
@@ -786,6 +820,7 @@ class FlowchartPreviewServer:
786
820
  event_handler.state["target_task"],
787
821
  event_handler.state["filter_completed"],
788
822
  event_handler.comparison,
823
+ self_outer.source_jump_server.url,
789
824
  )
790
825
  body_bytes = body.encode("utf-8")
791
826
  _send_preview_response(
@@ -797,7 +832,17 @@ class FlowchartPreviewServer:
797
832
  def log_message(self, format, *args):
798
833
  return
799
834
 
800
- self.httpd = http.server.ThreadingHTTPServer((self.host, 0), Handler)
835
+ self.source_jump_server = SourceJumpServer(event_handler.yaml_file)
836
+ self.source_jump_server.start()
837
+ self_outer = self
838
+ try:
839
+ self.httpd = http.server.ThreadingHTTPServer(
840
+ (self.host, 0), Handler
841
+ )
842
+ except Exception:
843
+ self.source_jump_server.stop()
844
+ self.source_jump_server = None
845
+ raise
801
846
  self.httpd.daemon_threads = True
802
847
  port = self.httpd.server_address[1]
803
848
  self.base_url = f"http://{self.host}:{port}/"
@@ -825,8 +870,13 @@ class FlowchartPreviewServer:
825
870
  self.httpd.server_close()
826
871
  if self.server_thread is not None:
827
872
  self.server_thread.join(timeout=1.0)
873
+ if self.source_jump_server is not None:
874
+ self.source_jump_server.stop()
875
+ self.source_jump_server = None
828
876
 
829
877
 
878
+ # also used by: pydifftools.command_registry, which dispatches registered
879
+ # commands
830
880
  @register_command(
831
881
  "Watch a flowchart YAML file, rebuild DOT/SVG output, and open the"
832
882
  " preview",
@@ -120,6 +120,29 @@
120
120
  },
121
121
  true
122
122
  );
123
+ svgDoc.addEventListener(
124
+ 'contextmenu',
125
+ function (evt) {
126
+ const node = evt.target.closest
127
+ ? evt.target.closest('g.node[data-source-name]')
128
+ : null;
129
+ if (!node) {
130
+ return;
131
+ }
132
+ evt.preventDefault();
133
+ const bounds = embed.getBoundingClientRect();
134
+ window.parent.document.dispatchEvent(
135
+ new window.parent.CustomEvent('pydifft-source-context', {
136
+ detail: {
137
+ phrase: node.getAttribute('data-source-name'),
138
+ clientX: bounds.left + evt.clientX,
139
+ clientY: bounds.top + evt.clientY,
140
+ },
141
+ })
142
+ );
143
+ },
144
+ true
145
+ );
123
146
  // }}}
124
147
 
125
148
  // {{{ Zoom-to-rectangle mode
@@ -0,0 +1,128 @@
1
+ """Local browser-to-editor requests for source jumps."""
2
+
3
+ import http.server
4
+ import re
5
+ import secrets
6
+ import subprocess
7
+ import threading
8
+ import urllib.parse
9
+
10
+
11
+ MAX_SOURCE_JUMP_BYTES = 8192
12
+ SOURCE_JUMP_WORDS = re.compile(r"[\w]+", re.UNICODE)
13
+
14
+
15
+ def _run_gvim_shell_command(*arguments):
16
+ """Run the user's interactive Bash ``gvim`` alias with safe arguments."""
17
+
18
+ subprocess.run(
19
+ ["bash", "-ic", 'gvim "$@"', "pydifft", *arguments],
20
+ check=True,
21
+ stdout=subprocess.DEVNULL,
22
+ stderr=subprocess.DEVNULL,
23
+ )
24
+
25
+
26
+ # also used by: SourceJumpServer's HTTP request handler and
27
+ # tests/test_source_jump.py
28
+ def jump_to_source(source_path, phrase):
29
+ """Open *source_path* through the user's gvim alias and run ``SW!``."""
30
+
31
+ words = [word.lower() for word in SOURCE_JUMP_WORDS.findall(phrase)]
32
+ if not words:
33
+ raise ValueError(
34
+ "The source-search phrase contains no searchable words"
35
+ )
36
+ if len(words) > 120:
37
+ words = words[:120]
38
+
39
+ _run_gvim_shell_command(str(source_path))
40
+ remote_keys = "<C-\\><C-N>:SW! " + " ".join(words) + "<CR>"
41
+ _run_gvim_shell_command(
42
+ "--servername", "GVIM", "--remote-send", remote_keys
43
+ )
44
+
45
+
46
+ class SourceJumpServer:
47
+ """An ephemeral loopback HTTP endpoint scoped to one preview session."""
48
+
49
+ def __init__(self, source_path):
50
+ self.source_path = str(source_path)
51
+ self.token = secrets.token_urlsafe(24)
52
+ self.httpd = None
53
+ self.thread = None
54
+ self.url = None
55
+
56
+ def start(self):
57
+ source_path = self.source_path
58
+ token = self.token
59
+
60
+ class Handler(http.server.BaseHTTPRequestHandler):
61
+ def do_POST(self):
62
+ parsed = urllib.parse.urlparse(self.path)
63
+ query = urllib.parse.parse_qs(parsed.query)
64
+ if (
65
+ parsed.path != "/jump"
66
+ or query.get("token", [None])[-1] != token
67
+ ):
68
+ self.send_error(404)
69
+ return
70
+ try:
71
+ content_length = int(
72
+ self.headers.get("Content-Length", "0")
73
+ )
74
+ except ValueError:
75
+ self.send_error(400)
76
+ return
77
+ if (
78
+ content_length <= 0
79
+ or content_length > MAX_SOURCE_JUMP_BYTES
80
+ ):
81
+ self.send_error(413)
82
+ return
83
+ try:
84
+ phrase = self.rfile.read(content_length).decode("utf-8")
85
+ except UnicodeDecodeError:
86
+ self.send_error(400)
87
+ return
88
+ try:
89
+ jump_to_source(source_path, phrase)
90
+ except (
91
+ OSError,
92
+ subprocess.SubprocessError,
93
+ ValueError,
94
+ ) as exc:
95
+ print(f"pydifft source jump failed: {exc}", flush=True)
96
+ self.send_error(500)
97
+ return
98
+ self.send_response(204)
99
+ self.send_header("Cache-Control", "no-store")
100
+ self.end_headers()
101
+
102
+ def log_message(self, format, *args):
103
+ return
104
+
105
+ self.httpd = http.server.ThreadingHTTPServer(
106
+ ("127.0.0.1", 0), Handler
107
+ )
108
+ self.httpd.daemon_threads = True
109
+ host, port = self.httpd.server_address
110
+ self.url = (
111
+ f"http://{host}:{port}/jump?token="
112
+ + urllib.parse.quote(self.token, safe="")
113
+ )
114
+ self.thread = threading.Thread(
115
+ target=self.httpd.serve_forever,
116
+ daemon=True,
117
+ )
118
+ self.thread.start()
119
+ return self.url
120
+
121
+ def stop(self):
122
+ if self.httpd is not None:
123
+ self.httpd.shutdown()
124
+ self.httpd.server_close()
125
+ self.httpd = None
126
+ if self.thread is not None:
127
+ self.thread.join(timeout=1.0)
128
+ self.thread = None