Showing preview only (653K chars total). Download the full file or copy to clipboard to get everything.
Repository: darrenldl/docfd
Branch: main
Commit: 203de25de255
Files: 125
Total size: 618.6 KB
Directory structure:
gitextract_8iyswmwt/
├── .gitattributes
├── .github/
│ └── workflows/
│ └── deploy.yml
├── .gitignore
├── CHANGELOG.md
├── LICENSE
├── Makefile
├── README.md
├── bin/
│ ├── BLAKE2B.ml
│ ├── UI.ml
│ ├── UI_base.ml
│ ├── args.ml
│ ├── clipboard.ml
│ ├── command.ml
│ ├── content_and_search_result_rendering.ml
│ ├── debug_utils.ml
│ ├── docfd.ml
│ ├── document.ml
│ ├── document.mli
│ ├── document_pipeline.ml
│ ├── document_pipeline.mli
│ ├── document_src.ml
│ ├── dune
│ ├── file_utils.ml
│ ├── filter_exp.ml
│ ├── glob.ml
│ ├── glob.mli
│ ├── lock_protected_cell.ml
│ ├── lock_protected_cell.mli
│ ├── misc_utils.ml
│ ├── params.ml
│ ├── path_opening.ml
│ ├── ping.ml
│ ├── ping.mli
│ ├── printers.ml
│ ├── proc_utils.ml
│ ├── result_syntax.ml
│ ├── script.ml
│ ├── search_mode.ml
│ ├── session.ml
│ ├── session.mli
│ ├── session_manager.ml
│ ├── session_manager.mli
│ ├── string_utils.ml
│ ├── version_string.ml
│ └── xdg_utils.ml
├── containers/
│ ├── Containerfile.demo-vhs
│ └── Containerfile.docfd
├── demo-vhs-tapes/
│ ├── repo-non-interactive.tape
│ ├── repo.tape
│ └── ui-screenshot.tape
├── demo-vhs.sh
├── docfd.opam
├── docfd.opam.locked
├── docfd.opam.template
├── dune-project
├── file-collection-tests.t/
│ ├── dune
│ └── run.t
├── lib/
│ ├── GZIP.ml
│ ├── char_map.ml
│ ├── doc_id_db.ml
│ ├── doc_id_db.mli
│ ├── docfd_lib.ml
│ ├── dune
│ ├── index.ml
│ ├── index.mli
│ ├── int_map.ml
│ ├── int_set.ml
│ ├── link.ml
│ ├── misc_utils.ml
│ ├── option_syntax.ml
│ ├── params.ml
│ ├── parser_components.ml
│ ├── search_exp.ml
│ ├── search_exp.mli
│ ├── search_phrase.ml
│ ├── search_phrase.mli
│ ├── search_result.ml
│ ├── search_result.mli
│ ├── search_result_heap.ml
│ ├── sqlite3_utils.ml
│ ├── stop_signal.ml
│ ├── stop_signal.mli
│ ├── string_map.ml
│ ├── string_set.ml
│ ├── task_pool.ml
│ ├── task_pool.mli
│ ├── tokenization.ml
│ ├── word_db.ml
│ └── word_db.mli
├── line-wrapping-tests.t/
│ ├── dune
│ ├── long-words.txt
│ ├── run.t
│ ├── sentences.txt
│ └── words.txt
├── match-type-tests.t/
│ ├── dune
│ ├── run.t
│ └── test.txt
├── misc-behavior-tests.t/
│ ├── abcd.txt
│ ├── dune
│ └── run.t
├── non-interactive-mode-return-code-tests.t/
│ ├── dune
│ └── run.t
├── open-with-tests.t/
│ ├── dune
│ └── run.t
├── printing-tests.t/
│ ├── empty.txt
│ ├── run.t
│ ├── test0.txt
│ ├── test1.txt
│ ├── test2.txt
│ ├── test3.txt
│ └── test4.txt
├── profiling/
│ ├── dune
│ └── main.ml
├── publish.sh
├── run-container.sh
├── script-tests.t/
│ ├── dune
│ └── run.t
├── search-scope-narrowing-tests.t/
│ ├── dune
│ └── run.t
├── tests/
│ ├── dune
│ ├── main.ml
│ ├── search_exp_tests.ml
│ ├── test_utils.ml
│ └── utils_tests.ml
└── update-version-string.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .gitattributes
================================================
*.t/run.t linguist-vendored
================================================
FILE: .github/workflows/deploy.yml
================================================
name: Deploy on release
on:
push:
tags:
- "[0-9]*"
- "test*"
branches:
- "ci-test"
jobs:
build:
strategy:
fail-fast: false
matrix:
os:
- ubuntu-22.04
- ubuntu-22.04-arm
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- run: echo "GITHUB_TAG=$(git describe --always --tags)" >> $GITHUB_ENV
- if: ${{ startsWith(matrix.os, 'ubuntu') && !endsWith(matrix.os, 'arm') }}
run: echo "OS_SHORT_NAME=linux" >> $GITHUB_ENV
- if: ${{ startsWith(matrix.os, 'ubuntu') && endsWith(matrix.os, 'arm') }}
run: echo "OS_SHORT_NAME=linux-arm" >> $GITHUB_ENV
- if: ${{ startsWith(matrix.os, 'macos') }}
run: echo "OS_SHORT_NAME=macos" >> $GITHUB_ENV
- if: ${{ startsWith(matrix.os, 'windows') }}
run: echo "OS_SHORT_NAME=windows" >> $GITHUB_ENV
- name: Set up OCaml for Linux
uses: ocaml/setup-ocaml@v3
with:
ocaml-compiler: "5.2.1"
- run: opam install dune
- run: opam install . --deps-only --with-test
- name: Use commit hash as version if on ci-test branch
if: ${{ github.ref_name == 'ci-test' }}
run: |
echo "DOCFD_VERSION_OVERRIDE=${{ env.GITHUB_TAG }}" >> $GITHUB_ENV
- name: Create build for macOS
if: ${{ env.OS_SHORT_NAME == 'macos' }}
run: |
export DOCFD_VERSION_OVERRIDE=${{ env.GITHUB_TAG }}
opam exec -- make release-build
- name: Create static build for Linux
if: ${{ env.OS_SHORT_NAME == 'linux' }}
run: |
export DOCFD_VERSION_OVERRIDE=${{ env.GITHUB_TAG }}
opam exec -- make release-static-build
- name: Create static build for Linux ARM
if: ${{ env.OS_SHORT_NAME == 'linux-arm' }}
run: |
export DOCFD_VERSION_OVERRIDE=${{ env.GITHUB_TAG }}
opam exec -- make release-static-build-arm
- name: Package into tar.gz
run: |
mv release/docfd docfd
tar -cvzf docfd-${{ env.GITHUB_TAG }}-${{ env.OS_SHORT_NAME }}.tar.gz docfd
- name: Upload artifacts
if: ${{ github.ref_name == 'ci-test' }}
uses: actions/upload-artifact@v4
with:
name: docfd-${{ env.GITHUB_TAG }}-${{ env.OS_SHORT_NAME }}.tar.gz
path: docfd-${{ env.GITHUB_TAG }}-${{ env.OS_SHORT_NAME }}.tar.gz
- name: Release
if: ${{ github.ref_name != 'ci-test' }}
uses: softprops/action-gh-release@v2
with:
files: |
docfd-${{ env.GITHUB_TAG }}-${{ env.OS_SHORT_NAME }}.tar.gz
- name: Release preview
if: ${{ github.ref_name == 'ci-test' }}
uses: softprops/action-gh-release@v2
with:
tag_name: preview
name: "Preview Build"
body: "Automated preview build from commit ${{ github.sha }}. This release is updated on every push to ci-test."
prerelease: true
files: |
docfd-${{ env.GITHUB_TAG }}-${{ env.OS_SHORT_NAME }}.tar.gz
================================================
FILE: .gitignore
================================================
_build/
_coverage/
.merlin
*.rst~
*.install
bisect*.out
bisect*.coverage
fuzz-*-input
fuzz-*-output
fuzz-logs/
/test*.md
/test*.txt
test*.pdf
test*.docx
*.tar.gz
/release/
perf.data*
*.pdf
.cache
/*.log
*.mp4
dummy.gif
*.db
*.db-journal
================================================
FILE: CHANGELOG.md
================================================
# Changelog
## 13.0.0
- Removed fzf dependency entirely
- Switched from fzf to a built-in implementation for fuzzy selection
menus
- Replaced "sort by fzf" functionality with built-in PATH-FUZZY-RANK mode
- Renamed OPEN-SCRIPT to SCRIPTS
- Moved DELETE-SCRIPT functionality to `Ctrl`+`X` under SCRIPTS
- Added `y` key binding to copy in LINKS
- Changed key binding for rotation of key binding info grid from `?` to `<` and `>`
- Added "focus content" via `z` which hides the bottom right pane
- Added key binding info pane toggle via `?`
- Made the UX of various input fields slightly more polished
- This includes adding `Esc` for cancelling during SAVE-SCRIPT
- Added request handling debouncing to reduce pointless workload triggered during
fast typing
- Improved link extraction
- Minor UI fixes and polishes
## 12.3.2
- Fixed missing stop signal passing for `content:"..."` filter expression handling
- Previously, filter expressions with `content:"..."` were not
cancelled properly
- Added additional guards against potential freezing due to DB pool
exhaustion or DB connection issues
## 12.3.1
- Fixed key binding info grid
- Added the missing `l` key binding info
- Fixed some other key binding labels
## 12.3.0
**Note**: This update contains a feature that requires removing the existing index DB to take effect.
- Docfd script comment support improvement
- Added `;` prefix for system comments, which are not preserved after editing of command history
- `#` now denotes user comment and is preserved after editing of command history
- Ordering of `#` is also preserved during saving a session as script
- Added link opening support via LINKS mode
- This will **only** work after recreating the index DB
- `l` opens LINKS mode with the same navigation keybinds
- `Enter` to open link
- `o` to open link and remain in LINKS
- Links which are closest to the selected search result will be prioritized first
- Added key binding `xh` to clear command history quickly
## 12.2.0
- Dependencies adjustment for CI build
- Internal refactoring
- Refactored document store module into session state module to
better reflect that the data structure is capturing not just
documents and search results, but also some UI states, etc
- Moved screen split handling to the level of session state instead of
plain UI state
- This allows Docfd script to better capture the view on screen
- Added `Shift`+`Tab` for changing screen split ratio in the other
direction, and removed the "rotating" behaviour of `Tab`
## 12.1.0
- Added missing sorting based on paths when path dates are the same
- Added PDF viewer integration for Zathura on Linux
- Moved sorting handling to the level of document store command instead of just plain UI update
- This allows Docfd script to better capture the view on screen
- Fixed default cache directory location on macOS
- Changed from `~/Library/Application Support` to `~/Library/Caches`
- Added `--reverse` to fzf invocation for better UX
- Added `Ctrl`+`D` for script deletion
## 12.0.0
This contains a **breaking** DB change, you will need to remove index DB generated by Docfd version prior to 12.0.0-alpha.13
#### Highlights of changes since 11.0.1
- Moved to using a global word table to reduce index DB size and speed up search (12.0.0-alpha.13)
- This is the **breaking** DB change stated above
- Added further search speed optimizations (12.0.0)
- Added an additional document pruning stage
- Added a first word candidate pruning stage based on length of the first search word
- Searching for short words should now feel much more responsive
- Replaced filter glob with a more powerful filter language, with
autocomplete in filter field (12.0.0-alpha.1, 12.0.0-alpha.2,
12.0.0-alpha.5, 12.0.0-alpha.6, 12.0.0-alpha.10, 12.0.0-alpha.11)
- Added content view pane scrolling (12.0.0-alpha.5, 12.0.0-alpha.8)
- Controlled by `-`/`=`
- Added "save script" and "load script" functionality to make it
actually viable to reuse Docfd commands (12.0.0-alpha.8,
12.0.0-alpha.9)
- SQL query optimizations for prefix and exact search terms
(12.0.0-alpha.3)
- Key binding info grid improvements (12.0.0-alpha.4)
- Added more key bindings
- Packed columns more tightly
- Added `--paths-from -` to accept list of paths from stdin
(12.0.0-alpha.3)
- Added WSL clipboard integration (12.0.0-alpha.4)
- Added more marking key bindings (12.0.0-alpha.4)
- `mark listed` (`ml`) marks all currently listed documents
- `unmark listed` (`Ml`) unmarks all currently listed documents
- `--open-with` placeholder handling fixes (12.0.0-alpha.4)
- Using `{page_num}` and `{line_num}` crashes in 11.0.1
when there are no search results
- Added sorting to document list (12.0.0-alpha.11, 12.0.0)
- `s` for sort ascending mode and `Shift+S` for sort descending mode
- Under the sort modes, the sort by types are as follows:
- `p` sort by path
- `d` sort by path date
- `s` sort by score
- `m` sort by modification time
- `f` sort by an interactive fzf search
- Selected option will be ranked the highest
- Rest of the documents will be ranked using the ranking from fzf
- Adjusted attributes listed in document list entry (12.0.0-alpha.11)
- Added path date
- Replaced last scan time with last modified time
- Reworked the internal architecture of document store snapshots
storage and management, which makes the overall interaction
between UI and core code much more robust (12.0.0-alpha.11)
#### Changes since 12.0.0-alpha.13
- Added further search speed optimizations:
- Added an additional document pruning stage
- Added a first word candidate pruning stage based on length of the first search word
- Searching for short words should now feel much more responsive
- Fixed interaction with fzf (which is used in some selection menus) on macOS
due to different behavior of `Unix.waitpid` on macOS compared to Linux
- Document sorting fine tuning
- Fixed document sorting fallback behavior
- If there is no search expression but sorting method chosen is to
sort by score, then sorting method falls back to the option
specified by `--sort-no-score`
- Fixed macOS detection
- Updated `--open-with` to accept a list of extensions, e.g.
`--open-with ts,js:detached="... {path}"`
- Added sort by fzf functionality
- Under sort mode
- `f` sort by an interactive fzf search
- Selected option will be ranked the highest
- Rest of the documents will be ranked using the ranking from fzf
## 12.0.0-alpha.13
- Moved to using a global word table to reduce index DB size and speed up search
- This is a **breaking** DB change, you will need to remove index DB generated by older versions of Docfd
- Added missing mutexes for caches, should further reduce random crashes
- Added more path date extraction formats
- `yyyy-mmm-dd`, `yyyy-mmmm-dd`, `dd-mmm-yyyy`, `dd-mmmm-yyyy`
- `-` is an optional separator that is not a digit and not a letter
## 12.0.0-alpha.12
- Made resetting of search result selection and content view offset less aggressive
- Some changes in 12.0.0-alpha.11 caused some UI counters to reset more frequently than desired
## 12.0.0-alpha.11
- Removed disabling of drop mode key binding `d` when searching or filtering is ongoing
- Fixed content view pane offset not resetting when mouse is used to scroll search result list
- Fixed content view pane staying small while scrolling up when the search result is close to the bottom of the file
- Swapped all mutexes to Eio mutexes to hopefully remove the very random freezes that occur quite rarely
- They feel like deadlocks due to mixing Eio mutexes
(which block fiber) and stdlib mutexes (which block an entire domain)
- Added sorting to document list
- `s` for sort ascending mode and `Shift+S` for sort descending mode
- Under the sort modes, the sort by types are as follows:
- `p` sort by path
- `d` sort by path date
- `s` sort by score
- `m` sort by modification time
- Added `--sort` and `--sort-no-score`
- Latter is mainly useful for when `--files-without-match` is used
- Added `yyyymmdd` path date extraction
- Added `mod-date` to filter language
- Adjusted attributes listed in document list entry
- Added path date
- Replaced last scan time with last modified time
- Reworked `--script` into `--script` and `--start-with-script`
- `--script` is now only for non-interactive use
- `--start-with-script` is only for interactive use
- This mirrors the duals `--filter` vs `--start-with-filter` and `--search` vs `--start-with-search`
- Reworked the internal architecture of document store snapshots storage and management
- Snapshots are now centrally managed by `Document_store_manager`, along with
improvements to snapshot handling logic in general
- This makes the overall interaction between UI and core code
much more robust, and eliminates random workarounds used to
deal with UI and data synchronization, which have
been riddled with random minor bugs
## 12.0.0-alpha.10
- Added basic autocomplete to filter field
- Improved script save autocomplete to insert longest common prefix
- Fixed script save autocomplete so it no longer erases original text when no recommendations are available
## 12.0.0-alpha.9
- Disabled `Tab` handling in edit fields to reduce friction in UX
- Added `nano`-style autocomplete to save commands field with listing of existing scripts
## 12.0.0-alpha.8
- Changed `--commands-from` to `--script`
- Added "save commands as script" and "load script" functionality to streamline reusing of commands
- Improved content view pane scrolling control
- The internal counter no longer scrolls past the limit
## 12.0.0-alpha.7
- Fixed interactive use of `--commands-from`
- Added `mark listed` and `unmark listed` to template command history file help info
## 12.0.0-alpha.6
- Fixed `not` operator parsing
- Previously `not ext:txt and not ext:md` would be parsed as `not (ext:txt and not ext:md)`, which is not what is typically expected
- `not` now binds tightly, so `not ext:txt and not ext:md` is parsed as `(not ext:txt) and (not ext:md)`
## 12.0.0-alpha.5
- Added content view pane scrolling
- Controlled by `-`/`=`
- Removed extraneous marking functionality
- `mark unlisted`
- `unmark unlisted`
- Added `"..."` as a shorthand to `content:"..."` to filter expression
- For example, `content:keyword AND path-date:>2025-01-01` can be written as `"keyword" AND path-date:>2025-01-01`
- The quotation is necessary to differentiate between typos
and actual query, otherwise incorrect input like
`pathfuzzy:...` would be parsed as content queries instead
## 12.0.0-alpha.4
- Added additional marking functionality
- `mark listed` (`ml`) marks all currently listed documents
- `mark unlisted` (`mL`) marks all currently unlisted documents
- `unmark listed` (`Ml`) unmarks all currently listed documents
- `unmark unlisted` (`ML`) unmarks all currently unlisted documents
- `unmark all` is moved to key binding `Ma`
- Reworked key binding info grid to pack columns more tightly
- Added WSL clipboard integration
- Minor fix in command history file template help text
- Added `Tab` key to key binding info grid
- Added key binding info about scrolling through document list and search result list
- Minor fix for `{line_num}` placeholder handling in `--open-with`
- This should always be usable for text files but previously
Docfd crashes when `{line_num}` is specified in `--open-with`
and user opens a text file when no search has been made
- This is fixed by defaulting `{line_num}` to 1 when
there are no search results present
- Minor fix for `{page_num}` and `{search_word}` placeholders handling in `--open-with`
- This should always be usable for PDF files but previously
Docfd crashes when `{page_num}` or `{search_word}` is specified in `--open-with`
and user opens a PDF file when no search has been made
- This is fixed by defaulting `{page_num}` to 1
and `{search_word}` to empty string when
there are no search results present
## 12.0.0-alpha.3
- **Users are advised to recreate the index DB**
- Adjusted SQL indices and swapped to specialized SQL queries
for exact and prefix search terms, e.g. `'hello`, `^worl`
- Handling of these terms is now 10-20% faster depending on the document
- Fixed command history recomputation not using the reloaded version
of document store
- This issue is most noticeable when you've edited a text file after hitting `Enter` in Docfd (after which Docfd reloads the file for you),
and you hit `h` to modify the command history
- The replaying of the command history would use the old copy of the file instead of the new edited version of the text file
- Added missing SQL transaction in code path for reloading a single document
- Previously, reloading a single document was incredibly slow, which was very noticeable if you edited a text file
after hitting `Enter` in Docfd, unless the text file was very small
- Updated `--paths-from` argument handling
- Added `--paths-from -` for accepting list of paths from stdin
- Adjusted to accept comma separated list of paths, e.g. `--paths-from path-list0.txt,path-list1.txt`
- Removed builtin piping to fzf triggered by providing `?` as a file path, e.g. `docfd ?`
- The `--paths-from -` handling makes this obsolete and a lot less flexible by comparison
- Fixed interaction between search and filter
- Previously, starting a search would incorrectly cancel an ongoing filtering operation.
Now only a new filtering operation can cancel an ongoing filtering operation.
A new search still cancels an ongoing search.
- Starting a new filtering operation also still cancels any ongoing search. This is fine since the search results
are refreshed after the filtering has been completed.
- The refreshing of the search results also means that the following sequences of events are still handled correctly,
namely they still arrive at the same normal form of the document store:
- Example 1:
- (0) Filter `f_exp0` (filtering is canceled by step (2), but the updating of filter expression is never canceled)
- (1) Search `s_exp0` (search is canceled by step (2), but the updating of search expression is never canceled)
- (2) Filter `f_exp1` (refreshes search results using `s_exp0`)
- Example 2:
- (0) Search `s_exp0` (search is canceled by step (1), but updating of search expression is never canceled)
- (1) Filter `f_exp0` (this stage is canceled by step (2),
either during the filtering or during the
refreshing of search results, but the updating
of filter expression is never canceled)
- (2) Filter `f_exp1` (refreshes search results using `s_exp0`)
- Renaming query expression/language to filter expression/language in help text and documentation
- Added a separate loading indicator for filter field
- Fixed concurrency issue where an update of document store may cause the
filter field and search field in UI to be out of sync with the actual
filter expression and search expression used by the underlying document store
- Suppose we have the following sequence of events:
- (0) Document store `store0` carries filter expression
`f_exp0` and search expression `s_exp0`, which we write
as pair `(f_exp0, s_exp0)`
- (1) User initiates filter/search operation by placing `(f_exp1, s_exp1)` into the input fields.
We name the document store resulting from this filter/search operation as `store1a`,
which carries `(f_exp1, s_exp1)` when finalized.
- (2) While filter/search operation is ongoing,
user drops a set of documents from the
current document store. Since `store1a` is not
finalized yet, the current document store is still `store0`, thus the new document store encoding the result of the drop operation, `store1b`, is computed from `store0` instead of `store1a`.
In other words, both `store1a` and `store1b` share
`store0` as their parent.
Note that `store1b` carries `(f_exp0, s_exp0)` as
inherited from `store0`,
since a drop operation does not alter the filter expression or search expression.
- (3) As a drop operation immediately updates the document store
and cancels ongoing filter/search operation, step (2) canceled the computation of `store1a`, and instead places `store1b` as the current document store.
- However, this means the input fields are `(f_exp1, s_exp1)`
while the current document store `store1b` actually carries
`(f_exp0, s_exp0)`.
The fix in this update is then to add an
extra "sync from input fields" step whenever a document store
is updated. To illustrate, we continue from the above
sequence of events, where the updated version of Docfd
carries out the following step missing from previous
versions.
- (4) Update input fields to `(f_exp0, s_exp0)`
- This addresses the mismatch between the underlying document store and the UI input fields.
- In practice this is very unlikely to occur with human input, as the modes that update document store
are disabled if document store manager is carrying out any ongoing filtering or search.
However, since the UI is async, there will be gaps in timing between UI input/feedback and actual updates of values,
opening up to TOCTOU problems.
So there is always a chance that a document store update will be requested before the modes are are disabled.
- Made interrupted filter/search operation to not yield a document store at all instead of yielding an empty document store
to simplify reasoning about filter/search cancellations and UI fields being in sync
## 12.0.0-alpha.2
- Added `path-date` clause to query expression
- This allows filtering based on date recognized from document path, for example, `path-date:>=2025-01-01 AND path-date:<2025-02-01`
would allow `/home/user/meeting-notes-2025-01-10.md` to pass through
- This gives a very lightweight method of attaching date information to any document
- See [relevant Wiki page](https://github.com/darrenldl/docfd/wiki/Document-filtering) for details
## 12.0.0-alpha.1
- Added a more powerful filter mode that replaces the filter glob mode and "pipe to fzf" feature
- Filter query mode uses a proper query language that supports file path globbing and file path fuzzy matching among other features
- This mode uses key binding `f`
- Removed `q` exit key binding to avoid accidental exiting
## 11.0.1
- Added better search cancellation handling, removing massive lags in some scenarios
## 11.0.0
- Minor fix for search scope narrowing logic:
- Search scope should also be set to empty if the document is not passing the file filter, not just when the search results are empty
- The old behavior can be confusing when a document passes an old file filter and thus has search results in memory,
but fail to pass a new file filter,
yet appears in later searches when file filter is reset
- It is simpler to make it so if a document is not listed for
whatever reason, search scope of that document just becomes
empty during narrowing
- Added missing commands in the list of possible commands in the command history file template
- `clear search`
- `clear filter`
- Minor breaking change, filter regex mode should have been called filter glob mode
- The key binding `fr` is changed to `fg`
- Changed UI text "File path filter" to "File path glob" to be more descriptive
## 10.2.0
- Added `--open-with` to allow customising the command used to open a file based on file extension
- Example: `--open-with pdf:detached='okular {path}'`
- Can be specified multiple times
- Added non-interactive use of `--commands-from`
- Non-interactive use can be triggered by pairing `--commands-from` with `-l`/`--files-with-match`
- Useful for advanced document management workflow
- Adjustments to search scope narrowing
- Added `narrow level: 0` for resetting the search scopes of
all documents back to full
- Narrowing now no longer drops unlisted document, so the
previous set of documents remain accessible for later
searches after resetting the search scopes
- Reworked search into multi-stage pipeline
- This improves the search speed by around 30%
- The core search procedure was reworked into an API that
generates grouped search jobs which can be easily distributed
to threads.
This gives a better workload distribution than the current
multithreading approach.
## 10.1.3
- Minor fixes
- "Reload document" now removes the document if the document is no longer accessible
- Docfd now only checks the existence of directly specified files
at launch, e.g. `file.txt` in `docfd file.txt`. This means
"reload all documents" now does not error out due to files becoming
no longer accessible.
## 10.1.2
- Minor fix for "reload all doucments" when fzf was used to pick documents initially, i.e. `docfd [PATH]... ?`, or any variation where `?` appears anywhere in the path list
- Under this workflow, later "reload all" should use the same selection
instead of having the user select again in fzf, which is cumbersome
- Now Docfd correctly reuses the selection when "reload all" is requested,
if fzf was used initially to pick documents
- This does technically mean the functionality is now less flexible,
since if `docfd ?` alike is used, "reload all" no longer discovers
new files
- But the convenience from reusing the selection outweighs the flexibility
in practically all use cases from author's experience
## 10.1.1
- Minor fix for "filter files via fzf" functionality
- Previously, if instead of making a selection,
the user quits fzf (e.g. pressing `Ctrl`+`C`, `Ctrl`+`Q`),
Docfd also closes with it
- Now Docfd just discards the interaction and goes back to the main UI
## 10.1.0
- Added back index DB entry pruning
- Previously missing after swapping to SQLite DB
- Also renamed `--cache-soft-limit` to `--cache-limit` to
reflect the new pruning logic
- Fixes [issue #12](https://github.com/darrenldl/docfd/issues/12)
- Swapped to a better `doc_id` allocation strategy to minimise
`doc_id` size in DB
- Added blinking when drop mode is disabled but `d` is pressed
## 10.0.0
- Reworked document indexing into a multi-stage pipeline
- This significantly improves the indexing throughput by allowing
I/O tasks and computational tasks to run concurrently
- See [issue #11](https://github.com/darrenldl/docfd/issues/11)
- **Breaking** changes in index DB design - index DBs made by previous version
of Docfd are not compatible
- Optimized DB design, on average the index DB is roughly 60% smaller
compared to Docfd 9.0.0 index DB
- See [issue #11](https://github.com/darrenldl/docfd/issues/11)
- Added functionality to filter files via fzf
- This is grouped under filter mode. The previous filter mode
is renamed to filter regex mode.
- `f` enters filter mode
- `f` again activates filter files via fzf functionality
- `r` activates the filter regex mode, which was previously
just called the filter mode
- Fixed incomplete search results when file path filter field is updated while
search is ongoing
- Updating file path filter always cancels the current search (if there is one)
and start a new search after the filter is in place
- Previously, documents with partial search results due to cancellation
are kept
- Docfd now discards said documents, forcing the new search to complete the
search results of these documents
- Removed `--no-cache` flag
- Previously was unused completey
- It is difficult to share an in-memory SQlite DB
between threads, so discarding this flag entirely
- See [issue #11](https://github.com/darrenldl/docfd/issues/11)
- Swapped to using proper unicode segmentation for tokenisation
- This should reduce the index size for Western non-English languages
significantly
- Added screen split ratios for hiding left or right pane completely
- Minor UI/UX fixes
- Drop mode is now disabled when search is still ongoing or when either search field or filter field has an error
- Added missing update of search and filter status when undoing/redoing, or when replaying command history
- This is most noticeable when the status indicates an error, but undoing does not return it to OK
## 9.0.0
- Swapped over to using SQLite for index
- Memory usage is much slimmer/stays flat
- For the sample of 1.4GB worth of PDFs used, after indexing, 9.0.0-rc1 uses
1.9GB of memory, while 9.0.0-rc2 uses 39MB
- Search is a bit slower
- Added token length limit of 500 bytes to accommodate word table limit in index DB
- This means during indexing, if Docfd encounters a very long token,
e.g. serial number, long hex string, it will be split into chunks of
up to 500 bytes
- Added `Ctrl`+`C` exit key binding to key binding info on screen
- Updated exit keys
- To exit Docfd: `q`, `Ctrl`+`Q` or `Ctrl`+`C`
- To exit other modes: `Esc`
- Now defaults to not scanning hidden files and directories
- This behaviour is now enabled via the `--hidden` flag
- Changed to allow `--add-exts` and `--single-line-add-exts` to be specified multiple times
- Changed return code to be 1 when there are no results for `--sample` or `--search`
- Added `--no-pdftotext` and `--no-pandoc` flags
- Docfd also notes the presence of these flags in error message if there
are PDF files but no pdftotext command is available, and same with files
relying on pandoc
- Renamed `drop path` command to just `drop`
- Added drop unselected key binding, and the associated command `drop all except`
- Various key binding help info grid adjustments
## 9.0.0-rc1
- Changed default cache size from 100 to 10000
- Index after compression doesn't take up that much space, and storage is
generally cheap enough these days
- Adjusted cache eviction behaviour to be less strict on when eviction happens
and thus less expensive
- Renamed `--cache-size` to `--cache-soft-limit`
- Removed periodic GC compact call to avoid freezes when working with many
files
- Removed GC compact call during file indexing core loops to reduce overhead
- Added progress bars to initial document processing stage
- Swapped to using C backend for BLAKE2B hashing, this gives >20x speedup depending on CPU
- Swapped from JSON+GZIP to CBOR+GZIP serialization for indices
- Changed help info rotation key from `h` to `?`
- Renamed discard mode to drop mode
- Added command history editing functionality
- Added `--commands-from` command line argument
- Added `--tokens-per-search-scope-level` command line argument
- Concurrency related bug fixes
- Unlikely to encounter in normal workflows with human input speed
- https://github.com/darrenldl/docfd/commit/14fcc45b746e6156f29eb989d70700476977a3d7
- https://github.com/darrenldl/docfd/commit/bfd63d93562f8785ecad8152005aa0f823185699
- https://github.com/darrenldl/docfd/commit/4e0aa6785ce80630d0cd3cda6e316b7b15a4fb4b
- Replaced print mode with copy mode
- Replaced single file view with key binding to change screen split ratio
to remove feature discrepencies
- Added narrow mode for search scope narrowing
- Renamed `--index-chunk-token-count` to `--index-chunk-size`
- Renamed `--sample-count-per-doc` to `--samples-per-doc`
## 8.0.3
- Fixed single file view crash
## 8.0.2
- Reworked asynchronous search/filter UI code to avoid noticeable lag due to
waiting for cancellations that take too long
- Previously there was still a lockstep somewhere that would prevent UI
from progressing if previous search was still being canceled
- The current implementation allows newest requests to override older
requests entirely, and not wait for cancellations at all
- Adjusted document counter in multi-file view to be visible even when no files
are listed
## 8.0.1
- Fixed missing file path filter field update when undoing or redoing document
store updates
- Fixed case insensitive marker handling in glob command line arguments
## 8.0.0
- Removed `--markdown-headings atx` from pandoc commandline
arguments
- Removed `Alt`+`U` undo key binding
- Removed `Alt`+`E` redo key binding
- Removed `Ctrl`+`Q` exit key binding
- Added documentation for undo, redo key bindings
- Added clear mode and moved clear search field key binding
under this mode for multi-file view
- Added file path filtering functionality to multi-file view
## 7.1.0
- Added initial macOS support
- Likely to have bugs, but will need macOS users to report back
- Major speedup from letting `pdftotext` output everything in one pass and split
on Docfd side instead of asking `pdftotext` to output one page per invocation
- For very large PDFs the indexing used to take minutes but now only takes
seconds
- Page count may be inaccurate if the PDF page contains form feed character
itself (not fully sure if `pdftotext` filters the form feed character from
content), but should be rare
- Significant reduction of index file size by adding GZIP
compression to the index JSON
## 7.0.0
- Added discard mode to multi-file view
- Changed to using thin bars as pane separators, i.e. tmux style
- Added `g` and `G` key bindings for going to top and bottom of document list respectively
- Added `-l`/`--files-with-match` and `--files-without-match` for printing just paths
in non-interactive mode
- Grouped print key bindings under print mode
- Added more print key bindings
- Grouped reload key bindings under reload mode
- Added fixes to ensure Docfd does not exit until all printing is done
- Slimmed down memory usage by switching to OCaml 5.2 which enables use of `Gc.compact`
- Still no auto-compaction yet, however, will need to wait for a future
OCaml release
- Added `h` key binding to rotate key binding info grid
- Added exact, prefix and suffix search syntax from fzf
- Fixed extraneous document path print in non-interactive mode when documents have no search results
- Added "explicit spaces" token `~` to match spaces
## 6.0.1
- Fixed random UI freezes when updating search field
- This is due to a race condition in the search cancellation mechanism that
may cause UI fiber to starve and wait forever for a cancellation
acknowledgement
- This mechanism was put in place for asynchronous search since 4.0.0
- As usual with race conditions, this only manifests under some specific
timing by chance
## 6.0.0
- Fixed help message of `--max-linked-token-search-dist`
- Fixed search result printing where output gets chopped off if terminal width is too small
- Added smart additional line grabbing for search result printing
- `--search-result-print-snippet-min-size N`
- If the search result to be printed has fewer than `N` non-space tokens,
then Docfd tries to add surrounding lines to the snippet
to give better context.
- `--search-result-print-snippet-max-add-lines`
- Controls maximum number of surrounding lines that can be added in each direction.
- Added search result underlining when output is not a terminal,
e.g. redirected to file, piped to another command
- Changed `--search` to show all search results
- Added `--sample` that uses `--search` previous behavior where (by default)
only a handful of top search results are picked for each document
- Changed `--search-result-count-per-doc` to `--sample-count-per-doc`
- Added `--color` and `--underline` for controlling behavior of search result
printing, they can take one of:
- `never`
- `always`
- `auto`
- Removed blinking for `Tab` key presses
## 5.1.0
- Fixed help message of `--max-token-search-dist`
- Adjusted path display in UI to hide current working directory segment when
applicable
- Added missing blinking for `Tab` key presses
## 5.0.0
- Added file globbing support in the form of `--glob` argument
- Added single line search mode arguments
- `--single-line-exts`
- `--single-line-add-exts`
- `--single-line-glob`
- `--single-line`
- Fixed crash on empty file
- This was due to assertion failure of `max_line_num` in
`Content_and_search_result_render.content_snippet`
- Changed search result printing via `Shift+P` and `p` within TUI to not exit
after printing, allowing printing of more results
- Added blinking to key binding info grid to give better visual feedback,
especially for the new behavior of search result printing
- Changed to allow `--paths-from` to be specified multiple times
- Fixed handling of `.htm` files
- `htm` is not a valid value for pandoc's `--format` argument
- Now it is rewritten to `html` before being passed to pandoc
- Changed `--max-depth`:
- Changed default from 10 to 100
- Changed to accept 0
## 4.0.0
- Made document search asynchronous to search field input, so UI remains
smooth even if search is slow
- Added status to search bar:
- `OK` means Docfd is idling
- `...` means Docfd is searching
- `ERR` means Docfd failed to parse the search expression
- Added search cancellation. Triggered by editing or clearing search field.
- Added dynamic search distance adjustment based on notion of linked tokens
- Two tokens are linked if there is no space between them,
e.g. `-` and `>` are linked in `->`, but not in `- >`
- Replaced `word` with `token` in the following options for consistency
- `--max-word-search-dist`
- `--index-chunk-word-count`
- Replaced `word` with `token` in user-facing text
## 3.0.0
- Fixed crash from search result snippet being bigger the content view pane
- Crash was from `Content_and_search_result_render.color_word_image_grid`
- Added key bindings
- `p`: exit and print search result to stderr
- `Shift+P`: exit and print file path to stderr
- Changed `--debug-log -` to use stderr instead of stdout
- Added non-interactive search mode where search results are printed to stdout
- `--search EXP` invokes non-interactive search mode with search expression `EXP`
- `--search-result-count-per-document` sets the number of top search results printed per document
- `--search-result-print-text-width` sets the text width to use when printing
- Added `--start-with-search` to prefill the search field in interactive mode
- Removed content requirement expression from multi-file view
- Originally designed for file filtering, but I have almost never used
it since its addition in 1.0.0
- Added word based line wrapping to following components of document list in multi-file view
- Document title
- Document path
- Document content preview
- Added word breaking in word based line wrapping logic so all of the original characters
are displayed even when the terminal width is very small or when a word/token is very long
- Added `--paths-from` to specify a file containing list of paths to (also) be scanned
- Fixed search result centering in presence of line wrapping
- Renamed `--max-fuzzy-edit` to `--max-fuzzy-edit-dist` for consistency
- Changed error messages to not be capitalized to follow Rust's and Go's
guidelines on error messages
- Added fallback rendering text so Docfd does not crash from trying
to render invalid text.
- Added pandoc integration
- Changed the logic of determining when to use stdin as document source
- Now if any paths are specified, stdin is ignored
- This change mostly came from Dune's cram test mechanism
not providing a tty to stdin, so previously Docfd would keep
trying to source from stdin even when explicit paths are provided
## 2.2.0
- Restored behaviour of skipping file extension checks for top-level
user specified files. This behaviour was likely removed during some
previous overhaul.
- This means, for instance, `docfd bin/docfd.ml` will now open the file
just fine without `--add-exts ml`
- Bumped default max word search distance from 20 to 50
- Added consideration for balanced opening closing symbols in search result ranking
- Namely symbol pairs: `()`, `[]`, `{}`
- Fixed crash from reading from stdin
- This was caused by calling `Notty_unix.Term.release` after closing the underlying
file descriptor in stdin input mode
- Added back handling of optional operator `?` in search expression
- Added test corpus to check translation of search expression to search phrases
## 2.1.0
- Added text editor integration for `jed`/`xjed`
- See [PR #3](https://github.com/darrenldl/docfd/pull/3)
by [kseistrup](https://github.com/kseistrup)
## 2.0.0
- Added "Last scan" field display to multi-file view and single file view
- Reduced screen flashing by only recreating `Notty_unix.Term.t` when needed
- Added code to recursively mkdir cache directory if needed
- Search procedure parameter tuning
- UI tuning
- Added search expression support
- Adjusted quit key bindings to be: `Esc`, `Ctrl+C`, and `Ctrl+Q`
- Added file selection support via `fzf`
## 1.9.0
- Added PDF viewer integration for:
- okular
- evince
- xreader
- atril
- mupdf
- Fixed change in terminal behavior after invoking text editor
by recreating `Notty_unix.Term.t`
- Fixed file auto-reloading to apply to all file types instead of
just text files
## 1.8.0
- Swapped to using Nottui at [a337a77](https://github.com/let-def/lwd/commit/a337a778001e6c1dbaed7e758c9e05f300abd388)
which fixes event handling, and pasting into edit field works correctly as a result
- Caching is now disabled if number of documents exceeds cache size
- Moved index cache to `XDG_CACHE_HOME/docfd`, which overall
defaults to `$HOME/.cache/docfd`
- Added cache related arguments
- `--cache-dir`
- `--cache-size`
- `--no-cache`
- Fixed search result centering in content view pane
- Changed `--debug` to `--debug-log` to support outputting debug log to a file
- Fixed file opening failure due to exhausting file descriptors
- This was caused by not bounding the number of concurrent fibers when loading files
via `Document.of_path` in `Eio.Fiber.List.filter_map`
- Added `--index-only` flag
- Fixed document rescanning in multi-file view
## 1.7.3
- Fixed crash from using mouse scrolling in multi-file view
- The mouse handler did not reset the search result selected
when selecting a different document
- This leads to out of bound access if the newly selected document
does not have enough search results
## 1.7.2
- Fixed content pane sometimes not showing all the lines
depending on terminal size and width of lines
- Made chunk size dynamic for parallel search
## 1.7.1
- Parallelization fine-tuning
## 1.7.0
- Added back parallel search
- General optimizations
- Added index file rotation
## 1.6.3
- Further underestimate space available for the purpose of line wrapping
## 1.6.2
- Fixed line wrapping
## 1.6.1
- Fixed line wrapping
## 1.6.0
- Docfd now saves stdin into a tmp file before processing
to allow opening in text editor
- Added `--add-exts` argument for additional file extensions
- Added real-time response to terminal size changes
## 1.5.3
- Updated key binding info pane of multi-file view
## 1.5.2
- Added line number into search result ranking consideration
## 1.5.1
- Tuned search procedure and search result ranking
- Made substring bidirectional matching differently weighted based
on direction
- Made reverse substring match require at least 3 characters
- Case-sensitive bonus only applies if search phrase
is not all ascii lowercase
## 1.5.0
- Made substring matching bidirectional
- Tuned search result ranking
## 1.4.0
- Moved reading of environment variables `VISUAL` and `EDITOR` to program start
- Performance tuning
- Increased cache size for search phrase automata
## 1.3.4
- Added dispatching of search to task pool at file granularity
## 1.3.3
- Performance tuning
- Switched back to using the old default max word search distance of 20
- Reduced default max fuzzy edit distance from 3 to 2 to prevent massive
slowdown on long words
## 1.3.2
- Performance tuning
- Added caching to search phrase automata construction
- Removed dispatching of search to task pool
- Adjusted search result limits
## 1.3.1
- Added more commandline argument error checking
- Adjusted help messages
- Adjusted max word search range calculation
- Renamed `max-word-search-range` to `max-word-search-dist`
## 1.3.0
- Index data structure optimizations
- Search procedure optimizations
## 1.2.2
- Fixed editor recognition for kakoune
## 1.2.1
- Fixed search results when multiple words are involved
## 1.2.0
- Removed UI components for search cancellation
- Added real time refresh of search
- Added code to open selected text file to selected search result for:
- nano
- neovim/vim/vi
- helix
- kakoune
- emacs
- micro
- Added "rescan for documents" to multi-file view
## 1.1.1
- Fixed releasing Notty terminal too early
## 1.1.0
- Added index saving and loading
- Added search cancellation
## 1.0.2
- Fixed file tree scan
## 1.0.1
- Minor UI tweaks
## 1.0.0
- Added expression language for file filtering in multi-file view
- Adjusted default file tree depth
- Added `--exts` argument for configuring file extensions recognized
- Fixed parameters passing from binary to library
## 0.9.0
- Added PDF search support via `pdftotext`
- Added UTF-8 support
## 0.8.6
- Minor wording fix
## 0.8.5
- Added check to skip re-searching if search phrase is equivalent to the previous one
## 0.8.4
- Index data structure optimization
- Code cleanup
## 0.8.3
- Optimized multi-file view reload so it does not redo the search over all documents
- Implemented a proper document store
## 0.8.2
- Fixed single file view document reloading not refreshing search results
## 0.8.1
- Replaced shared data structures with multicore safe versions
- Fixed work partitioning for parallel indexing
## 0.8.0
- Added multicore support for indexing and searching
## 0.7.4
- Fixed crashing and incorrect rendering in some cases of files with blank lines
- This is due to `Index.line_count` being incorrectly calculated
- Added auto refresh on change of file
- Change detection is based on file modification time
- Added reload file via `r` key
## 0.7.3
- Bumped the default word search range from 15 to 40
- Since spaces are also counted as words in the index,
15 doesn't actually give a lot of range
- Added minor optimization to search
## 0.7.2
- Code refactoring
## 0.7.1
- Delayed `Nottui_unix` term creation so pre TUI
printing like `--version` would work
- Added back mouse scrolling support
- Added Page Up and Page Down keys support
## 0.7.0
- Fixed indexing bug
- Added UI mode switch
- Adjusted status bar to show current file name in single file mode
- Adjusted content view to track search result
- Added content view to single file mode
## 0.6.3
- Adjusted status bar to not display index of document selected
when in single document mode
- Edited debug message a bit
## 0.6.2
- Fixed typo in error message
## 0.6.1
- Added check of whether provided files exist
## 0.6.0
- Upgraded status bar and help text/key binding info
## 0.5.9
- Changed help text to status bar + help text
## 0.5.8
- Fixed debug print of file paths
- Tuned UI text slightly
## 0.5.7
- Changed word db to do global word recording to further reduce memory footprint
## 0.5.6
- Optimized overall memory footprint
- Content index memory usage
- Switched to using content index to render content
lines instead of storing file lines again after indexing
## 0.5.5
- Fixed weighing of fuzzy matches
- Fixed bug in scoring of substring matches
## 0.5.4
- Fixed handling of search phrase with uppercase characters
- Prioritized search results that match the case
## 0.5.3
- Cleaned up code
## 0.5.2
- Cleaned up code
## 0.5.1
- Cleaned up code and debug info print a bit
## 0.5.0
- Removed tags handling
- Added stdin piping support
## 0.4.1
- Tuning content search result scoring
## 0.4.0
- Improved content search result scoring
- Added limit on content search results to consider to avoid
slowdown
- General optimizations
## 0.3.3
- Fixed crash due to not resetting content search result selection
when changing document selection
## 0.3.2
- Fixed internal line numbering, but displayed line numbering
still begins at 1
## 0.3.1
- Adjusted line number to begin at 1
## 0.3.0
- Adjusted colouring
## 0.2.9
- Fixed word position tracking in content indexing
## 0.2.8
- Fixed content indexing
## 0.2.7
- Changed to vim style highlighting for content search results
- Color adjustments in general
## 0.2.6
- Added single file UI mode
- Added support for specifying multiple files in command line
## 0.2.5
- Added limit to word search range of each step in content search
- This speeds up usual search while giving good enough results,
and prevents search from becoming very slow in large documents
## 0.2.4
- Adjusted displayed document list size
- Updated style of document list view
## 0.2.3
- Added sanitization to file view text
- Docfd now accepts file being passed as argument
## 0.2.2
- Fixed tokenization of user provided content search input
- Fixed content indexing to not include spaces
## 0.2.1
- Optimized file discovery procedure
- Added `--max-depth` option to limit scanning depth
- Added content search results view
- Adjusted tokenization procedure
## 0.2.0
- Switched to interactive TUI
- Renamed to Docfd
## 0.1.6
- Optimized parsing code slightly
## 0.1.5
- Adjusted parsing code slightly
## 0.1.4
- Adjusted `--tags` and `--ltags` output slightly
## 0.1.3
- Upgraded `--tags` and `--ltags` output to be more human readable
when output is terminal
- Changed behavior to output each tag in individual line when output
is not terminal
## 0.1.2
- Fixed output text when output is not terminal
## 0.1.1
- Fixed checking of whether output is terminal
## 0.1.0
- Flipped output positions of file path and tags
## 0.0.9
- Notefd now adds color to title and matching tags if output is terminal
- Improved fuzzy search index building
## 0.0.8
- Code cleanup
## 0.0.7
- Made file recognition more lenient
- Added support for alternative tag section syntax
- `| ... |`
- `@ ... @`
## 0.0.6
- Fixed Notefd to only handle consecutive tag sections
## 0.0.5
- Added `--tags` and `--ltags` flags
- Adjusted parsing to allow multiple tag sections
## 0.0.4
- Fixed tag extraction
## 0.0.3
- Made header extraction more robust to files with very long lines
## 0.0.2
- Added `-s` for case-insensitive substring tag match
- Renamed `-p` to `-e` for exact tag match
## 0.0.1
- Base version
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2022 Di Long Li
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: Makefile
================================================
SRCFILES = lib/*.ml lib/*.mli bin/*.ml bin/*.mli profiling/*.ml tests/*.ml
OCPINDENT = ocp-indent \
--inplace \
$(SRCFILES)
.PHONY: all
all :
python3 update-version-string.py
dune build @all
.PHONY: podman-build
podman-build:
podman build --format docker -t localhost/docfd -f containers/Containerfile.docfd .
.PHONY: podman-build-demo-vhs
podman-build-demo-vhs:
podman build --format docker -t localhost/docfd-demo-vhs -f containers/Containerfile.demo-vhs .
.PHONY: lock
lock:
opam lock .
.PHONY: release-build
release-build :
python3 update-version-string.py
dune build --release bin/docfd.exe
mkdir -p release
cp -f _build/default/bin/docfd.exe release/docfd
chmod 755 release/docfd
.PHONY: release-static-build
release-static-build :
python3 update-version-string.py
OCAMLPARAM='_,ccopt=-static' dune build --release bin/docfd.exe
mkdir -p release
cp -f _build/default/bin/docfd.exe release/docfd
chmod 755 release/docfd
.PHONY: release-static-build-arm
release-static-build-arm :
python3 update-version-string.py
OCAMLPARAM='_,ccopt=-static,fPIC' dune build --release bin/docfd.exe
mkdir -p release
cp -f _build/default/bin/docfd.exe release/docfd
chmod 755 release/docfd
.PHONY: tests
tests :
# Cleaning and rebuilding here to make sure cram tests actually use a recent binary,
# since Dune (as of 3.14.0) doesn't trigger rebuild of binary when
# invoking cram tests, even if the source code has changed.
make clean
make
OCAMLRUNPARAM=b dune exec tests/main.exe --no-buffer --force
dune build @file-collection-tests
dune build @line-wrapping-tests
dune build @misc-behavior-tests
dune build @printing-tests
dune build @match-type-tests
dune build @open-with-tests
dune build @non-interactive-mode-return-code-tests
dune build @search-scope-narrowing-tests
dune build @script-tests
.PHONY: demo-vhs
demo-vhs :
for file in demo-vhs-tapes/*; do ./demo-vhs.sh $$file; done
rm dummy.gif
.PHONY: profile
profile :
OCAMLPARAM='_,ccopt=-static' dune build --release profiling/main.exe
.PHONY: format
format :
$(OCPINDENT)
.PHONY : clean
clean:
dune clean
================================================
FILE: README.md
================================================
# Docfd
[Online Demo](https://demo.docfd.sh)
TUI multiline fuzzy document finder
Think interactive grep for text files, PDFs, DOCXs, etc,
but word/token based instead of regex and line based,
so you can search across lines easily.
Docfd aims to provide good UX via integration with common text editors
and PDF viewers,
so you can jump directly to a search result with a single key press.
---
Interactive use

Non-interactive use

## Features
- Multithreaded indexing and searching
- Multiline fuzzy search of multiple files
- Content view pane that shows the snippet surrounding the search result selected
- Text editor and PDF viewer integration
- Editable command history - rewrite/plan your actions in text editor
- Search scope narrowing - limit scope of next search based on current search results
- Clipboard integration
## Why Docfd might be for you
<details>
<summary>
You want a standalone, offline TUI search tool that
allows you to immediately start searching without any complicated setup.
</summary>
Docfd only starts processing the current directory or
specified directories/files upon start.
Hashing is used to pick out files that have not been indexed yet.
There is no need to wait for a background indexer to refresh
before you get up-to-date results.
</details>
<details>
<summary>
You don't want to move everything into a central storage, and want to just keep your current folder structure
</summary>
There are no strings attached with using Docfd.
Docfd does not require you to import your files into
any special storage system, so you can continue mix and match
tools to best handle your files.
</details>
<details>
<summary>
You want to script or record your search
</summary>
Docfd comes with a simple scripting language,
which is already used to capture your actions in the TUI.
Finally found what you need after many steps?
Save the session as a script with `Ctrl`+`S`!
Then open it next time with `Ctrl`+`O`.
</details>
## Why Docfd might not be for you
<details>
<summary>
Docfd is not all-encompassing
</summary>
Docfd does not try to be a full blown document management system such as Paperless-ngx.
While there may be significant overlaps in terms of the search functionality, Docfd will fall short for almost any other kind of features, such as storage management, tagging, web interface, OCR, email ingestion.
</details>
<details>
<summary>
Docfd is not a "proper" search engine
</summary>
Docfd is a search engine in the sense that it uses the same
fundamental principles, i.e. inverted indices, but it lacks features
that you would expect from a "proper" search engine solution, e.g.
[Apache Lucene](https://lucene.apache.org/),
[Tantivy](https://github.com/quickwit-oss/tantivy),
[Lnx](https://github.com/lnx-search/lnx).
Here are some of the fundamental features which I think are crucial to a proper search engine, but Docfd lacks:
- You cannot customize what are indexed by Docfd
- You cannot add a new type of ranking
- Docfd lacks support for languages other than English
- Docfd does not scale very well to very large quantity of documents
- Search should still be serviceable when you reach beyond, say, 10k documents, but it will be noticeably more sluggish
Some of these shortcomings are fundamental to the goals of Docfd. For instance,
Docfd is primarily a standalone desktop TUI tool with quick startup and should not impact other desktop applications.
As such, some performance related engineering choices typical for a proper search engine
are difficult to accommodate as they require longer startup and significantly more memory usage.
Other shortcomings are due to limited time and limited return on efforts - if one is to push Docfd so much to reach the feature parity
and performance of a proper search engine, then one might as well just use an existing search engine to begin with.
</details>
<details>
<summary>
If your notes are consistently very short, and you only want to do simple searches, then there are better options
</summary>
If you follow note taking methodologies such as Zettelkasten, where each note consists of very few lines, then using a combination of grep and file preview tool can yield a much faster search experience by skipping out on indexing and consideration of word proximity.
</details>
<details>
<summary>
Docfd does not "stream" its search results
</summary>
One user feedback received was that searching felt slow when Docfd is still conducting the search as UI is not updated result by result. By comparison, fzf felt faster as results start to immediately pop into the screen.
It is fundamentally more difficult to implement this streaming behavior nicely in Docfd, as Docfd operates with snapshots in mind (e.g. allowing you to undo/redo commands), while fzf does not. More specifically, it is much easier to wait for all search results to be ready, and finalize as a snapshot before presenting onto Docfd UI.
So while possible to implement in Docfd, it is unclear if the effort is worthwhile with the additional system complexity in mind.
</details>
## Installation
Statically linked binaries for Linux and macOS are available via
[GitHub releases](https://github.com/darrenldl/docfd/releases).
Docfd is also packaged on the following platforms for Linux:
- [opam](https://ocaml.org/p/docfd/latest)
- [AUR](https://aur.archlinux.org/packages/docfd-bin) (as `docfd-bin`)
- First packaged by [@kseistrup](https://github.com/kseistrup), now maintained by Dominiquini
- Nix (as `docfd`)
- Packaged by [@chewblacka](https://github.com/chewblacka)
The only way to use Docfd on Windows right now is via WSL.
**Notes for packagers**: Outside of the OCaml toolchain for building (if you are
packaging from source), Docfd also requires the following
external tools at run time for full functionality:
- `pdftotext` from `poppler-utils` for PDF support
- `pandoc` for support of `.epub`, `.odt`, `.docx`, `.fb2`, `.ipynb`, `.html`, and `.htm` files
- `wl-clibpard` for clipboard support on Wayland
- `xclip` for clipboard support on X11
## Basic usage
The typical usage of Docfd is to either `cd` into the directory of interest
and launch `docfd` directly, or specify the paths as arguments:
```
docfd [PATH]...
```
The list of paths can contain directories.
Each directory in the list is scanned recursively for
files with the following extensions by default:
- For multiline search mode:
- `.txt`,
`.md`,
`.pdf`,
`.epub`,
`.odt`,
`.docx`,
`.fb2`,
`.ipynb`,
`.html`,
`.htm`
- For single line search mode:
- `.log`,
`.csv`,
`.tsv`
You can change the file extensions to use via
`--exts` and `--single-line-exts`,
or add onto the list of extensions via
`--add-exts` and `--single-line-add-exts`.
If the list `PATH`s is empty,
then Docfd defaults to scanning the
current directory `.`
unless any of the following is used:
`--paths-from`, `--glob`, `--single-line-glob`.
## Documentation
See [GitHub Wiki](https://github.com/darrenldl/docfd/wiki) for
more examples/cookbook, and technical details.
## Changelog
[CHANGELOG](CHANGELOG.md)
## Limitations
- Docfd generally expects one intance per index DB
- You should pick a different cache directory (which houses
the index DB) via `--cache-dir`
if you need multiple instances
- There are safe guards to avoid corruptions even if you do run
multiple instances of Docfd, but note that the instances of Docfd
may exit unexpectedly
- That being said, running multiple instances of Docfd which are only reading
the index DB and not updating it should be fine
- File auto-reloading is not supported for PDF files,
as PDF viewers are invoked in the background via shell.
It is possible to support this properly
in the ways listed below, but requires
a lot of engineering for potentially very little gain:
- Docfd waits for PDF viewer to terminate fully
before resuming, but this
prohibits viewing multiple search results
simultaneously in different PDF viewer instances.
- Docfd manages the launched PDF viewers completely,
but these viewers are closed when Docfd terminates.
- Docfd invokes the PDF viewers via shell
so they stay open when Docfd terminates.
Docfd instead periodically checks if they are still running
via the PDF viewers' process IDs,
but this requires handling forks.
- Outside of tracking whether the PDF viewer instances
interacting with the files are still running,
Docfd also needs to set up file update handling
either via `inotify` or via checking
file modification times periodically.
## Acknowledgement
- Big thanks to [@lunacookies](https://github.com/lunacookies) and
[@jthvai](https://github.com/jthvai) for the many UI/UX discussions and
suggestions
- Demo gifs and some screenshots are made using [vhs](https://github.com/charmbracelet/vhs).
- [ripgrep-all](https://github.com/phiresky/ripgrep-all) was used as reference
for text extraction software choices
- [Marc Coquand](https://mccd.space) (author of
[Stitch](https://git.mccd.space/pub/stitch/)) for discussions and inspiration
of results narrowing functionality
- Part of the search syntax was copied from [fzf](https://github.com/junegunn/fzf)
- Command history editing workflow was inspired by Git interactive rebase workflow, e.g. `git rebase -i`
- [PDF corpora](https://github.com/pdf-association/pdf-corpora) from PDF association was used to stress test performance
================================================
FILE: bin/BLAKE2B.ml
================================================
module B = Digestif.Make_BLAKE2B (struct
let digest_size = 64
end)
let hash_of_file ~env ~path =
let fs = Eio.Stdenv.fs env in
let ctx = ref B.empty in
try
Eio.Path.(with_open_in (fs / path))
(fun flow ->
match
Eio.Buf_read.parse ~max_size:Params.hash_chunk_size
(fun buf ->
try
while true do
ctx := B.feed_string !ctx (Eio.Buf_read.take Params.hash_chunk_size buf)
done
with
| End_of_file ->
ctx := B.feed_string !ctx (Eio.Buf_read.take_all buf)
)
flow
with
| Ok () -> Ok (!ctx |> B.get |> B.to_hex)
| Error (`Msg msg) -> Error msg
)
with
| _ -> Error (Printf.sprintf "failed to hash file: %s" (Filename.quote path))
================================================
FILE: bin/UI.ml
================================================
open Docfd_lib
open Lwd_infix
module Vars = struct
let script_name_field = Lwd.var UI_base.empty_text_field
let script_name_field_focus_handle = Nottui.Focus.make ()
let path_fuzzy_rank_field = Lwd.var UI_base.empty_text_field
let path_fuzzy_rank_field_focus_handle = Nottui.Focus.make ()
let script_files : string Dynarray.t Lwd.var = Lwd.var (Dynarray.create ())
let usable_script_files : (string Dynarray.t * Int_set.t Dynarray.t option) Lwd.t =
let$* arr = Lwd.get script_files in
let$* input_mode = Lwd.get UI_base.Vars.input_mode in
let$ script_name_specified, _ = Lwd.get script_name_field in
match input_mode with
| Save_script -> (
(Dynarray.filter
(CCString.starts_with ~prefix:script_name_specified)
arr,
None)
)
| Scripts | Delete_script_confirm (_, _) -> (
match Search_exp.parse script_name_specified with
| None -> (
(Dynarray.filter
(CCString.starts_with ~prefix:script_name_specified)
arr,
None)
)
| Some exp -> (
if Search_exp.is_empty exp then (
(arr, None)
) else (
let ranking =
Misc_utils.fuzzy_rank_assoc
(Stop_signal.make ())
~get_key:Filename.chop_extension
exp
(Dynarray.to_seq arr)
in
Dynarray.to_seq ranking
|> Seq.map (fun (path, search_result) ->
(path, Misc_utils.highlights_of_search_result search_result)
)
|> Seq.split
|> (fun (s0, s1) ->
(Dynarray.of_seq s0, Some (Dynarray.of_seq s1)))
)
)
)
| _ -> (
(Dynarray.create (), None)
)
end
let refresh_script_files () =
Lwd.set UI_base.Vars.index_of_script_selected 0;
File_utils.list_files_recursive_filter_by_exts
~max_depth:1
~report_progress:(fun () -> ())
~exts:[ Params.docfd_script_ext ]
(Seq.return (Params.script_dir ()))
|> String_set.to_seq
|> Seq.map Filename.basename
|> Dynarray.of_seq
|> Lwd.set Vars.script_files
let reload_document (doc : Document.t) =
let pool = UI_base.task_pool () in
let path = Document.path doc in
let doc =
match
Document.of_path
~env:(UI_base.eio_env ())
pool
~already_in_transaction:false
(Document.search_mode doc)
path
with
| Ok doc -> Some doc
| Error _ -> (
None
)
in
let session_state =
Session_manager.lock_with_view (fun view ->
view.init_state
)
|> (fun state ->
match doc with
| Some doc -> (
Session.State.add_document pool doc state
)
| None -> (
Session.State.drop (`Path path) state
)
)
in
Session_manager.update_starting_state session_state
let reload_document_selected
~(search_result_groups : Session.search_result_group array)
: unit =
if Array.length search_result_groups > 0 then (
let index = Lwd.peek UI_base.Vars.index_of_document_selected in
let doc, _search_results = search_result_groups.(index) in
reload_document doc;
)
let toggle_mark ~path =
Session_manager.update_from_cur_snapshot
(fun cur_snapshot ->
let state = Session.Snapshot.state cur_snapshot in
let new_command =
if
String_set.mem
path
(Session.State.marked_document_paths state)
then (
`Unmark path
) else (
`Mark path
)
in
state
|> Session.run_command
(UI_base.task_pool ())
new_command
|> Option.get
|> (fun (new_command, state) ->
Session.Snapshot.make
~last_command:(Some new_command)
state)
)
let drop ~document_count (choice : [`Path of string | `All_except of string | `Marked | `Unmarked | `Listed | `Unlisted]) =
let new_command =
match choice with
| `Path path -> (
let n = Lwd.peek UI_base.Vars.index_of_document_selected in
UI_base.set_document_selected ~choice_count:(document_count - 1) n;
`Drop path
)
| `All_except path -> (
UI_base.set_document_selected ~choice_count:1 0;
`Drop_all_except path
)
| `Marked -> (
UI_base.reset_document_selected ();
`Drop_marked
)
| `Unmarked -> (
UI_base.reset_document_selected ();
`Drop_unmarked
)
| `Listed -> (
UI_base.reset_document_selected ();
`Drop_listed
)
| `Unlisted -> (
UI_base.reset_document_selected ();
`Drop_unlisted
)
in
Session_manager.update_from_cur_snapshot (fun cur_snapshot ->
Session.Snapshot.state cur_snapshot
|> Session.run_command
(UI_base.task_pool ())
new_command
|> Option.get
|> (fun (new_command, state) ->
Session.Snapshot.make
~last_command:(Some new_command)
state)
)
let mark (choice : [`Path of string | `Listed]) =
let new_command =
match choice with
| `Path path -> `Mark path
| `Listed -> `Mark_listed
in
Session_manager.update_from_cur_snapshot (fun cur_snapshot ->
Session.Snapshot.state cur_snapshot
|> Session.run_command
(UI_base.task_pool ())
new_command
|> Option.get
|> (fun (new_command, state) ->
Session.Snapshot.make
~last_command:(Some new_command)
state)
)
let unmark (choice : [`Path of string | `Listed | `All]) =
let new_command =
match choice with
| `Path path -> `Unmark path
| `Listed -> `Unmark_listed
| `All -> `Unmark_all
in
Session_manager.update_from_cur_snapshot (fun cur_snapshot ->
Session.Snapshot.state cur_snapshot
|> Session.run_command
(UI_base.task_pool ())
new_command
|> Option.get
|> (fun (new_command, state) ->
Session.Snapshot.make
~last_command:(Some new_command)
state)
)
let sort (sort_by : Command.Sort_by.t) =
UI_base.reset_document_selected ();
let new_command = `Sort (sort_by, Command.Sort_by.default_no_score) in
Session_manager.update_from_cur_snapshot (fun cur_snapshot ->
Session.Snapshot.state cur_snapshot
|> Session.run_command
(UI_base.task_pool ())
new_command
|> Option.get
|> (fun (new_command, state) ->
Session.Snapshot.make
~last_command:(Some new_command)
state)
)
let narrow_search_scope_to_level ~level =
Session_manager.update_from_cur_snapshot (fun cur_snapshot ->
Session.Snapshot.make
~last_command:(Some (`Narrow_level level))
(Session.State.narrow_search_scope_to_level
~level
(Session.Snapshot.state cur_snapshot))
)
let update_filter ~commit () =
let s = fst @@ Lwd.peek UI_base.Vars.filter_field in
Session_manager.submit_filter_req ~commit s
let update_search ~commit () =
let s = fst @@ Lwd.peek UI_base.Vars.search_field in
Session_manager.submit_search_req ~commit s
let update_path_fuzzy_rank ~commit () =
let s = fst @@ Lwd.peek Vars.path_fuzzy_rank_field in
Session_manager.submit_path_fuzzy_rank_req ~commit s
let compute_save_script_path base_name =
let dir = Params.script_dir () in
File_utils.mkdir_recursive dir;
Filename.concat
dir
(Fmt.str "%s%s" base_name Params.docfd_script_ext)
let save_script ~path =
Session_manager.stop_filter_and_search_and_restore_input_fields ();
let lines =
Session_manager.lock_with_view (fun view ->
view.snapshots
|> Dynarray.to_seq
|> Seq.filter_map (fun (snapshot : Session.Snapshot.t) ->
Option.map
Command.to_string
(Session.Snapshot.last_command snapshot)
)
|> List.of_seq
)
in
try
CCIO.with_out path (fun oc ->
CCIO.write_lines_l oc lines;
)
with
| Sys_error _ -> (
Misc_utils.exit_with_error_msg
(Fmt.str "failed to write script %s" path)
)
module Top_pane = struct
module Document_list = struct
let render_document_entry
~input_mode
~width
~documents_marked
~(search_result_group : Session.search_result_group)
~path_highlights
~selected
: Notty.image =
let open Notty in
let open Notty.Infix in
let (doc, search_results) = search_result_group in
let search_result_score_image =
if Option.is_some !Params.debug_output then (
if Array.length search_results = 0 then
I.empty
else (
let x = search_results.(0) in
I.strf "(Best search result score: %f)" (Search_result.score x)
)
) else (
I.empty
)
in
let sub_item_base_left_padding = I.string A.empty " " in
let sub_item_width = width - I.width sub_item_base_left_padding - 2 in
let preview_image =
let preview_left_padding_per_line =
I.string A.(bg lightgreen) " "
<|>
I.string A.empty " "
in
let preview_line_images =
let line_count =
min Params.preview_line_count (Index.global_line_count ~doc_id:(Document.doc_id doc))
in
OSeq.(0 --^ line_count)
|> Seq.map (fun global_line_num ->
Index.words_of_global_line_num ~doc_id:(Document.doc_id doc) global_line_num
|> Dynarray.to_list
|> Content_and_search_result_rendering.Text_block_rendering.of_words ~width:sub_item_width
)
|> Seq.map (fun img ->
let left_padding =
OSeq.(0 --^ I.height img)
|> Seq.map (fun _ -> preview_left_padding_per_line)
|> List.of_seq
|> I.vcat
in
left_padding <|> img
)
|> List.of_seq
in
I.vcat preview_line_images
in
let path_highlights =
match input_mode with
| UI_base.Path_fuzzy_rank -> (
String_map.find_opt (Document.path doc) path_highlights
)
| _ -> (
None
)
in
let path_image =
Document.path doc
|> File_utils.remove_cwd_from_path
|> Tokenization.tokenize ~drop_spaces:false
|> List.of_seq
|> Content_and_search_result_rendering.Text_block_rendering.of_words
~width:sub_item_width
?highlights:path_highlights
in
let path_image_with_prefix =
(I.string A.(fg lightgreen) "@ ")
<|>
path_image
in
let path_date_image =
(match Document.path_date doc with
| None -> I.void 0 0
| Some date -> (
I.string A.(fg lightgreen) " ⤷ "
<|>
I.string A.empty
(Timedesc.Date.to_rfc3339 date)
)
)
in
let last_modified_image =
I.string A.(fg lightgreen) "Last modified: "
<|>
I.string A.empty
(Timedesc.to_string ~format:Params.last_modified_format_string (Document.mod_time doc))
in
let marked = String_set.mem (Document.path doc) documents_marked in
let title =
let attr =
if selected then (
A.(fg lightblue ++ st bold)
) else (
A.(fg lightblue)
)
in
match Document.title doc with
| None ->
I.void 0 1
| Some title -> (
title
|> Tokenization.tokenize ~drop_spaces:false
|> List.of_seq
|> Content_and_search_result_rendering.Text_block_rendering.of_words ~attr ~width
)
in
match input_mode with
| UI_base.Path_fuzzy_rank -> (
path_image
)
| _ -> (
(
(if marked then I.strf "> " else I.void 0 1)
<|>
title
)
<->
(
sub_item_base_left_padding
<|>
I.vcat
[ search_result_score_image;
path_image_with_prefix;
path_date_image;
preview_image;
last_modified_image;
]
)
)
let main
~width
~height
~documents_marked
~(search_result_groups : Session.search_result_group array)
~(document_selected : int)
: Nottui.ui Lwd.t =
let document_count = Array.length search_result_groups in
let$* input_mode = Lwd.get UI_base.Vars.input_mode in
let$* (_cur_ver, snapshot) = Session_manager.cur_snapshot in
let state = Session.Snapshot.state snapshot in
let render_pane () =
let rec aux index height_filled acc =
if index < document_count
&& height_filled < height
then (
let selected = Int.equal document_selected index in
let img =
render_document_entry
~input_mode
~width
~documents_marked
~search_result_group:search_result_groups.(index)
~path_highlights:(Session.State.path_highlights state)
~selected
in
aux (index + 1) (height_filled + Notty.I.height img) (img :: acc)
) else (
List.rev acc
|> List.map Nottui.Ui.atom
|> Nottui.Ui.vcat
)
in
if document_count = 0 then (
Nottui.Ui.empty
) else (
aux document_selected 0 []
)
in
let$ background = UI_base.full_term_sized_background in
Nottui.Ui.join_z background (render_pane ())
|> Nottui.Ui.mouse_area
(UI_base.mouse_handler
~f:(fun direction ->
let offset =
match direction with
| `Up -> -1
| `Down -> 1
in
let document_current_choice =
Lwd.peek UI_base.Vars.index_of_document_selected
in
UI_base.set_document_selected
~choice_count:document_count
(document_current_choice + offset);
)
)
end
module Right_pane = struct
module Search_result_list = struct
let main
~height
~width
~(search_result_group : Session.search_result_group)
~(index_of_search_result_selected : int Lwd.var)
: Nottui.ui Lwd.t =
let (document, search_results) = search_result_group in
let search_result_selected = Lwd.peek index_of_search_result_selected in
let result_count = Array.length search_results in
if result_count = 0 then (
Lwd.return Nottui.Ui.empty
) else (
let images =
Misc_utils.array_sub_seq
~start:search_result_selected
~end_exc:(min result_count (search_result_selected + height))
search_results
|> Seq.map (Content_and_search_result_rendering.search_result
~doc_id:(Document.doc_id document)
~render_mode:(UI_base.render_mode_of_document document)
~width
)
|> List.of_seq
in
let pane =
images
|> List.map (fun img ->
Nottui.Ui.atom Notty.I.(img <-> strf "")
)
|> Nottui.Ui.vcat
in
let$ background = UI_base.full_term_sized_background in
Nottui.Ui.join_z background pane
|> Nottui.Ui.mouse_area
(UI_base.mouse_handler
~f:(fun direction ->
let n = Lwd.peek index_of_search_result_selected in
let offset =
match direction with
| `Up -> -1
| `Down -> 1
in
UI_base.set_search_result_selected
~choice_count:result_count
(n + offset)
)
)
)
end
module Link_list = struct
let main
~height
~width
~(search_result_group : Session.search_result_group)
~(index_of_link_selected : int Lwd.var)
: Nottui.ui Lwd.t =
let (document, _search_results) = search_result_group in
let links = Document.links document in
let link_selected = Lwd.peek index_of_link_selected in
let link_count = Array.length links in
if link_count = 0 then (
Lwd.return Nottui.Ui.empty
) else (
let start = max 0 (link_selected - height / 2) in
let end_exc = min link_count (start + height) in
let render ~width s =
s
|> Tokenization.tokenize ~drop_spaces:false
|> List.of_seq
|> Content_and_search_result_rendering.Text_block_rendering.of_words
~width
in
let pane =
Misc_utils.array_sub_seq ~start ~end_exc
links
|> Seq.map (fun link -> link.Link.link)
|> List.of_seq
|> Content_and_search_result_rendering.centered_list
~height
~width
~render
(link_selected - start)
|> Nottui.Ui.atom
in
let$ background = UI_base.full_term_sized_background in
Nottui.Ui.join_z background pane
|> Nottui.Ui.mouse_area
(UI_base.mouse_handler
~f:(fun direction ->
let n = Lwd.peek index_of_link_selected in
let offset =
match direction with
| `Up -> -1
| `Down -> 1
in
UI_base.set_link_selected
~choice_count:link_count
(n + offset)
)
)
)
end
let main
~width
~height
~(search_result_groups : Session.search_result_group array)
~(document_selected : int)
~show_bottom_right_pane
: Nottui.ui Lwd.t =
if Array.length search_result_groups = 0 then (
let blank ~height =
let _ = height in
Nottui_widgets.empty_lwd
in
UI_base.vpane ~width ~height
blank blank
) else (
let$* input_mode = Lwd.get UI_base.Vars.input_mode in
let$* search_result_selected = Lwd.get UI_base.Vars.index_of_search_result_selected in
let$* link_selected = Lwd.get UI_base.Vars.index_of_link_selected in
let search_result_group = search_result_groups.(document_selected) in
if show_bottom_right_pane then (
UI_base.vpane ~width ~height
(UI_base.Content_view.main
~input_mode
~width
~search_result_group
~search_result_selected
~link_selected)
(match input_mode with
| Links -> (
Link_list.main
~width
~search_result_group
~index_of_link_selected:UI_base.Vars.index_of_link_selected
)
| _ -> (
Search_result_list.main
~width
~search_result_group
~index_of_search_result_selected:UI_base.Vars.index_of_search_result_selected
))
) else (
UI_base.Content_view.main
~input_mode
~width
~height
~search_result_group
~search_result_selected
~link_selected
)
)
end
let item_list
~width
~height
~selected
?highlights
items
: Nottui.ui Lwd.t =
let arr = Dynarray.create () in
Seq.iter (fun i ->
Dynarray.add_last arr (Dynarray.get items i)
) OSeq.(selected --^ Dynarray.length items);
Dynarray.to_seq arr
|> Seq.mapi (fun i s ->
let highlights =
Option.map (fun highlights ->
Dynarray.get highlights (selected + i)
)
highlights
in
s
|> Tokenization.tokenize ~drop_spaces:false
|> List.of_seq
|> Content_and_search_result_rendering.Text_block_rendering.of_words
~width
?highlights
|> Nottui.Ui.atom
)
|> List.of_seq
|> Nottui.Ui.vcat
|> Nottui.Ui.resize ~w:width ~h:height
|> Lwd.return
let main
~width
~height
~documents_marked
~screen_split
~show_bottom_right_pane
~(search_result_groups : Session.search_result_group array)
: Nottui.ui Lwd.t =
let$* input_mode = Lwd.get UI_base.Vars.input_mode in
let$* script_selected = Lwd.get UI_base.Vars.index_of_script_selected in
let$* usable_scripts, usable_script_highlights =
Vars.usable_script_files
in
let file =
if script_selected < Dynarray.length usable_scripts then (
Some (Dynarray.get usable_scripts script_selected)
) else (
None
)
in
let$* selected = Lwd.get UI_base.Vars.index_of_script_selected in
match input_mode with
| Save_script -> (
item_list
~width
~height
~selected
usable_scripts
)
| Scripts | Delete_script_confirm (_, _) -> (
let lines =
try
match file with
| None -> []
| Some file -> (
let dir = Params.script_dir () in
CCIO.with_in (Filename.concat dir file) (fun ic ->
CCIO.read_lines_seq ic
|> OSeq.take height
|> List.of_seq
)
)
with
| Sys_error _ -> []
in
UI_base.hpane ~l_ratio:0.5 ~width ~height
(item_list
~height
~selected
?highlights:usable_script_highlights
usable_scripts)
(fun ~width ->
List.map (fun s -> Nottui.Ui.atom (Notty.I.strf "%s" s)) lines
|> Nottui.Ui.vcat
|> Nottui.Ui.resize ~w:width ~h:height
|> Lwd.return
)
)
| _ -> (
let$* document_selected = Lwd.get UI_base.Vars.index_of_document_selected in
let l_ratio =
match screen_split with
| `Even -> 0.50
| `Focus_left -> 1.0
| `Wide_left -> 0.618
| `Focus_right -> 0.0
| `Wide_right -> 1.0 -. 0.618
in
UI_base.hpane ~l_ratio ~width ~height
(Document_list.main
~height
~documents_marked
~search_result_groups
~document_selected)
(Right_pane.main
~height
~search_result_groups
~document_selected
~show_bottom_right_pane)
)
end
module Bottom_pane = struct
let status_bar
~width
~(search_result_groups : Session.search_result_group array)
~(input_mode : UI_base.input_mode)
: Nottui.Ui.t Lwd.t =
let open Notty.Infix in
let input_mode_image =
UI_base.Input_mode_map.find input_mode UI_base.Status_bar.input_mode_images
in
let attr = UI_base.Status_bar.attr in
let$* usable_scripts, _usable_script_highlights = Vars.usable_script_files in
let$* script_selected = Lwd.get UI_base.Vars.index_of_script_selected in
let usable_script_count = Dynarray.length usable_scripts in
let selected_script_file_and_path =
if usable_script_count > 0 then (
let dir = Params.script_dir () in
let file = Dynarray.get usable_scripts script_selected in
Some (file, Filename.concat dir file)
) else (
None
)
in
match input_mode with
| Save_script | Scripts -> (
let text_field = Vars.script_name_field in
let prompt =
match input_mode with
| Save_script -> "Save as"
| Scripts -> "Filter"
| _ -> failwith "unexpected case"
in
let on_tab =
match input_mode with
| Save_script -> (
Some (fun (text, _) ->
let best_fit =
let usable_script_count = Dynarray.length usable_scripts in
if usable_script_count = 0 then (
text
) else if usable_script_count = 1 then (
Filename.chop_extension (Dynarray.get usable_scripts 0)
) else (
usable_scripts
|> Dynarray.to_seq
|> String_utils.longest_common_prefix
)
in
Lwd.set text_field (best_fit, String.length best_fit)
)
)
| _ -> None
in
let on_submit =
match input_mode with
| Save_script -> (
(fun (text, _x) ->
Lwd.set text_field UI_base.empty_text_field;
Nottui.Focus.release Vars.script_name_field_focus_handle;
Lwd.set UI_base.Vars.input_mode
(if String.length text = 0 then
Save_script_no_name
else
Save_script_overwrite text
);
)
)
| Scripts -> (
(fun (_text, _x) ->
Option.iter (fun (_file, path) ->
Lwd.set text_field UI_base.empty_text_field;
Nottui.Focus.release Vars.script_name_field_focus_handle;
Lwd.set UI_base.Vars.quit true;
UI_base.Vars.action := Some (Open_script path);
Lwd.set UI_base.Vars.input_mode Navigate;
) selected_script_file_and_path;
)
)
| _ -> failwith "unexpected case"
in
let on_up_down =
match input_mode with
| Save_script -> None
| Scripts -> (
Some (fun up_down _ ->
UI_base.set_script_selected
~choice_count:usable_script_count
(script_selected +
(match up_down with
| `Up -> (-1)
| `Down -> 1)
))
)
| _ -> failwith "unexpected case"
in
let on_ctrl_prefixed =
match input_mode with
| Scripts -> (
Some (fun key (_text, _x) ->
match key with
| (`ASCII 'X', [`Ctrl]) -> (
Option.iter (fun (file, path) ->
Lwd.set UI_base.Vars.input_mode (Delete_script_confirm (file, path))
) selected_script_file_and_path;
`Handled
)
| _ -> (
`Unhandled
)
)
)
| _ -> None
in
let$* content =
Nottui_widgets.hbox
[
Lwd.return
(Nottui.Ui.atom
(Notty.I.hcat
[
input_mode_image;
UI_base.Status_bar.element_spacer;
Notty.I.strf ~attr "%s: [ " prompt;
]));
UI_base.edit_field text_field
~focus:Vars.script_name_field_focus_handle
~on_cancel:(fun (_, _) ->
Lwd.set UI_base.Vars.input_mode Navigate
)
~on_change:(fun (text, x) ->
Lwd.set text_field (text, x);
)
~on_submit
?on_tab
?on_up_down
?on_ctrl_prefixed;
Lwd.return (Nottui.Ui.atom (Notty.I.strf ~attr " ] + %s" Params.docfd_script_ext));
]
in
let$ bar = UI_base.Status_bar.background_bar in
Nottui.Ui.join_z bar content
)
| Save_script_overwrite script_name -> (
let path = compute_save_script_path script_name in
if Sys.file_exists path then (
let$* content =
Lwd.return
(Nottui.Ui.atom
(Notty.I.hcat
[
input_mode_image;
UI_base.Status_bar.element_spacer;
Notty.I.strf ~attr "%s already exists, overwrite?"
(Filename.basename path);
]))
in
let$ bar = UI_base.Status_bar.background_bar in
Nottui.Ui.join_z bar content
) else (
save_script ~path;
Lwd.set UI_base.Vars.input_mode (Save_script_edit script_name);
UI_base.Status_bar.background_bar
)
)
| Save_script_no_name -> (
let$* content =
Lwd.return
(Nottui.Ui.atom
(Notty.I.hcat
[
input_mode_image;
UI_base.Status_bar.element_spacer;
Notty.I.strf ~attr "No name entered, saving skipped";
]))
in
let$ bar = UI_base.Status_bar.background_bar in
Nottui.Ui.join_z bar content
)
| Save_script_edit script_name -> (
let path = compute_save_script_path script_name in
let$* content =
Lwd.return
(Nottui.Ui.atom
(Notty.I.hcat
[
input_mode_image;
UI_base.Status_bar.element_spacer;
Notty.I.strf ~attr "Do you want to edit %s to add comments etc?" (Filename.basename path);
]))
in
let$ bar = UI_base.Status_bar.background_bar in
Nottui.Ui.join_z bar content
)
| Delete_script_confirm (script, _) -> (
let$* content =
Lwd.return
(Nottui.Ui.atom
(Notty.I.hcat
[
input_mode_image;
UI_base.Status_bar.element_spacer;
Notty.I.strf ~attr "Confirm deletion of %s?" script;
]))
in
let$ bar = UI_base.Status_bar.background_bar in
Nottui.Ui.join_z bar content
)
| Path_fuzzy_rank -> (
let text_field = Vars.path_fuzzy_rank_field in
let document_count = Array.length search_result_groups in
let$* document_current_choice = Lwd.get UI_base.Vars.index_of_document_selected in
let$* content =
Nottui_widgets.hbox
[
Lwd.return
(Nottui.Ui.atom
(Notty.I.hcat
[
input_mode_image;
UI_base.Status_bar.element_spacer;
]));
UI_base.edit_field text_field
~focus:Vars.path_fuzzy_rank_field_focus_handle
~on_cancel:(fun (_, _) ->
Lwd.set UI_base.Vars.input_mode Navigate
)
~on_change:(fun (text, x) ->
Lwd.set text_field (text, x);
update_path_fuzzy_rank ~commit:false ();
)
~on_submit:(fun (text, x) ->
Lwd.set text_field (text, x);
update_path_fuzzy_rank ~commit:true ();
Lwd.set UI_base.Vars.input_mode Navigate
)
~on_up_down:(fun ud _ ->
let offset =
match ud with
| `Up -> -1
| `Down -> 1
in
UI_base.set_document_selected
~choice_count:document_count
(document_current_choice + offset);
)
;
]
in
let$ bar = UI_base.Status_bar.background_bar in
Nottui.Ui.join_z bar content
)
| _ -> (
let$* index_of_document_selected = Lwd.get UI_base.Vars.index_of_document_selected in
let document_count = Array.length search_result_groups in
let$* (_cur_ver, snapshot) = Session_manager.cur_snapshot in
let content =
let file_shown_count =
Notty.I.strf ~attr
"%5d/%d documents listed"
document_count
(Session.Snapshot.state snapshot
|> Session.State.size)
in
let hint =
Notty.I.strf ~attr "< and > to see more key binding info, ? to toggle hide"
in
let hint_len = Notty.I.width hint in
let hint_overlay =
Notty.I.void
(width - hint_len) 1
<|>
hint
in
let core =
if document_count = 0 then (
[
UI_base.Status_bar.element_spacer;
file_shown_count;
]
) else (
let index_of_selected =
Notty.I.strf ~attr
"Index of document selected: %d"
index_of_document_selected
in
[
file_shown_count;
UI_base.Status_bar.element_spacer;
index_of_selected;
]
)
in
Notty.I.zcat
[
Notty.I.hcat
(input_mode_image
::
UI_base.Status_bar.element_spacer
::
core);
hint_overlay;
]
|> Nottui.Ui.atom
in
let$ bar = UI_base.Status_bar.background_bar in
Nottui.Ui.join_z bar content
)
module Key_binding_info = struct
let grid_contents : UI_base.Key_binding_info.grid_contents =
let open UI_base.Key_binding_info in
let empty_row =
[
{ label = ""; msg = "" };
]
in
let navigate_grid =
[
[
{ label = "Enter"; msg = "open document" };
{ label = "/"; msg = "SEARCH" };
{ label = "↑/↓/j/k"; msg = "select document" };
{ label = "s"; msg = "SORT-ASC" };
{ label = "Tab"; msg = "expand right pane" };
{ label = "y"; msg = "COPY" };
{ label = "n"; msg = "NARROW" };
{ label = "Space"; msg = "toggle mark" };
{ label = "h"; msg = "command history" };
{ label = "Ctrl+S"; msg = "save session as script" };
];
[
{ label = "v"; msg = "focus content" };
{ label = "f"; msg = "FILTER" };
{ label = "Shift+↑/↓/j/k"; msg = "select search result" };
{ label = "Shift+S"; msg = "SORT-DESC" };
{ label = "Shift+Tab"; msg = "expand left pane" };
{ label = "Shift+Y"; msg = "COPY-PATHS" };
{ label = "d"; msg = "DROP" };
{ label = "m"; msg = "MARK" };
{ label = ""; msg = "" };
{ label = "Ctrl+O"; msg = "SCRIPTS" };
];
[
{ label = "Ctrl+C"; msg = "exit" };
{ label = "x"; msg = "CLEAR" };
{ label = "-/="; msg = "scroll content view" };
{ label = "l"; msg = "LINKS" };
{ label = ""; msg = "" };
{ label = ""; msg = "" };
{ label = "r"; msg = "RELOAD" };
{ label = "Shift+M"; msg = "UNMARK" };
{ label = ""; msg = "" };
{ label = ""; msg = "" };
];
]
in
let search_grid =
[
[
{ label = "Enter"; msg = "exit SEARCH" };
];
]
in
let filter_grid =
[
[
{ label = "Enter"; msg = "exit FILTER" };
{ label = "Tab"; msg = "autocomplete" };
];
]
in
let save_script_grid =
[
[
{ label = "Enter"; msg = "confirm answer" };
{ label = "Tab"; msg = "autocomplete" };
{ label = "Esc"; msg = "cancel" };
];
empty_row;
empty_row;
]
in
let save_script_confirm_grid =
[
[
{ label = "y"; msg = "confirm overwrite" };
{ label = "Esc/n"; msg = "cancel" };
];
empty_row;
empty_row;
]
in
let save_script_cancel_grid =
[
[
{ label = "Enter"; msg = "confirm" };
];
empty_row;
empty_row;
]
in
let save_script_edit_grid =
[
[
{ label = "y"; msg = "open in editor" };
{ label = "Esc/n"; msg = "skip" };
];
empty_row;
empty_row;
]
in
let scripts_grid =
[
[
{ label = "Enter"; msg = "open" };
{ label = "↑/↓"; msg = "select" };
{ label = "Ctrl+X"; msg = "delete" };
{ label = "Esc"; msg = "cancel" };
];
empty_row;
empty_row;
]
in
let delete_script_confirm_grid =
[
[
{ label = "y"; msg = "confirm deletion" };
{ label = "Esc/n"; msg = "cancel" };
];
empty_row;
empty_row;
]
in
let clear_grid =
[
[
{ label = "/"; msg = "search field" };
{ label = "f"; msg = "filter field" };
{ label = "h"; msg = "command history" };
];
[
{ label = "Esc"; msg = "cancel" };
];
empty_row;
]
in
let sort_asc_grid =
[
[
{ label = "s"; msg = "score" };
{ label = "p"; msg = "path" };
{ label = "d"; msg = "path date" };
{ label = "m"; msg = "mod time" };
];
[
{ label = "Esc"; msg = "cancel" };
];
empty_row;
]
in
let sort_desc_grid =
[
[
{ label = "s"; msg = "score" };
{ label = "p"; msg = "path" };
{ label = "d"; msg = "path date" };
{ label = "m"; msg = "mod time" };
];
[
{ label = "Esc"; msg = "cancel" };
];
empty_row;
]
in
let drop_grid =
[
[
{ label = "d"; msg = "selected" };
{ label = "l"; msg = "listed" };
{ label = "m"; msg = "marked" };
];
[
{ label = "Shift+D"; msg = "unselected" };
{ label = "Shift+L"; msg = "unlisted" };
{ label = "Shift+M"; msg = "unmarked" };
];
[
{ label = "Esc"; msg = "cancel" };
];
]
in
let mark_grid =
[
[
{ label = "l"; msg = "listed" };
];
empty_row;
[
{ label = "Esc"; msg = "cancel" };
];
]
in
let unmark_grid =
[
[
{ label = "l"; msg = "listed" };
{ label = "a"; msg = "all" };
];
empty_row;
[
{ label = "Esc"; msg = "cancel" };
];
]
in
let copy_grid =
[
[
{ label = "y"; msg = "selected search result" };
{ label = "m"; msg = "results of marked documents" };
{ label = "l"; msg = "results of listed documents" };
];
[
{ label = "a"; msg = "results of selected document" };
];
[
{ label = "Esc"; msg = "cancel" };
];
]
in
let copy_paths_grid =
[
[
{ label = "y"; msg = "path of selected document" };
{ label = "m"; msg = "paths of marked documents" };
{ label = "l"; msg = "paths of listed documents" };
];
[
{ label = ""; msg = "" };
{ label = "Shift+M"; msg = "paths of unmarked documents" };
{ label = "Shift+L"; msg = "paths of unlisted documents" };
];
[
{ label = "Esc"; msg = "cancel" };
];
]
in
let narrow_grid =
[
[
{ label = "0-9"; msg = "narrow search scope to level N" };
];
[
{ label = "Esc"; msg = "cancel" };
];
empty_row;
]
in
let reload_grid =
[
[
{ label = "r"; msg = "selected" };
{ label = "a"; msg = "all" };
];
[
{ label = "Esc"; msg = "cancel" };
];
empty_row;
]
in
let links_grid =
[
[
{ label = "Enter"; msg = "open" };
{ label = "o"; msg = "open and remain in LINKS" };
{ label = "↑/↓/j/k"; msg = "select" };
{ label = "y"; msg = "copy" };
];
[
{ label = "Esc"; msg = "exit" };
];
empty_row;
]
in
let path_fuzzy_rank_grid =
[
[
{ label = "Enter"; msg = "pin to top" };
{ label = "↑/↓"; msg = "select" };
];
[
{ label = "Esc"; msg = "exit" };
];
empty_row;
]
in
[
(Navigate, navigate_grid);
(Search, search_grid);
(Filter, filter_grid);
(Clear, clear_grid);
(Sort `Asc, sort_asc_grid);
(Sort `Desc, sort_desc_grid);
(Drop, drop_grid);
(Mark, mark_grid);
(Unmark, unmark_grid);
(Narrow, narrow_grid);
(Copy, copy_grid);
(Copy_paths, copy_paths_grid);
(Reload, reload_grid);
(Save_script, save_script_grid);
(Save_script_overwrite "", save_script_confirm_grid);
(Save_script_no_name, save_script_cancel_grid);
(Save_script_edit "", save_script_edit_grid);
(Scripts, scripts_grid);
(Delete_script_confirm ("", ""), delete_script_confirm_grid);
(Links, links_grid);
(Path_fuzzy_rank, path_fuzzy_rank_grid);
]
let grid_lookup = UI_base.Key_binding_info.make_grid_lookup grid_contents
let main ~input_mode ~show_key_binding_info_pane =
if show_key_binding_info_pane then (
UI_base.Key_binding_info.main ~grid_lookup ~input_mode
) else (
Lwd.return (Nottui.Ui.atom (Notty.I.void 0 0))
)
end
let autocomplete_grid ~input_mode ~width =
match input_mode with
| UI_base.Filter -> (
let$* l = Lwd.get UI_base.Vars.autocomplete_choices in
let max_len =
List.fold_left (fun n x ->
max n (String.length x)
) 0 l
in
let cell_len = max_len + 4 in
let cells_per_row = width / cell_len in
l
|> CCList.chunks cells_per_row
|> (fun rows ->
let row_count = List.length rows in
let padding =
if row_count < 2 then (
CCList.(0 --^ (2 - row_count))
|> List.map (fun _ -> [ "" ])
) else (
[]
)
in
rows @ padding
)
|> List.map (fun row ->
List.map (fun s ->
let full_background = Notty.I.void cell_len 1 in
Notty.I.(strf "%s" s </> full_background)
|> Nottui.Ui.atom
|> Lwd.return
) row
)
|> Nottui_widgets.grid
~pad:(Nottui.Gravity.make ~h:`Negative ~v:`Negative)
)
| _ -> Lwd.return (Nottui.Ui.atom (Notty.I.void 0 0))
let filter_bar =
UI_base.Filter_bar.main
~text_field:UI_base.Vars.filter_field
~focus_handle:UI_base.Vars.filter_field_focus_handle
~on_change:(update_filter ~commit:false)
~on_submit:(update_filter ~commit:true)
let search_bar ~input_mode =
UI_base.Search_bar.main ~input_mode
~text_field:UI_base.Vars.search_field
~focus_handle:UI_base.Vars.search_field_focus_handle
~on_change:(update_search ~commit:false)
~on_submit:(update_search ~commit:true)
let main ~width ~show_key_binding_info_pane ~search_result_groups =
let$* input_mode = Lwd.get UI_base.Vars.input_mode in
Nottui_widgets.vbox
[
status_bar ~width ~search_result_groups ~input_mode;
Key_binding_info.main ~input_mode ~show_key_binding_info_pane;
autocomplete_grid ~input_mode ~width;
filter_bar ~input_mode;
search_bar ~input_mode;
]
end
let keyboard_handler
~(session_state : Session.State.t)
~(search_result_groups : Session.search_result_group array)
(key : Nottui.Ui.key)
=
let document_count =
Array.length search_result_groups
in
let document_current_choice =
Lwd.peek UI_base.Vars.index_of_document_selected
in
let search_result_group =
if document_count = 0 then
None
else
Some search_result_groups.(document_current_choice)
in
let search_result_choice_count =
match search_result_group with
| None -> 0
| Some (_doc, search_results) -> Array.length search_results
in
let link_choice_count =
match search_result_group with
| None -> 0
| Some (doc, _search_results) -> Array.length (Document.links doc)
in
let search_result_current_choice =
Lwd.peek UI_base.Vars.index_of_search_result_selected
in
let link_current_choice =
Lwd.peek UI_base.Vars.index_of_link_selected
in
match Lwd.peek UI_base.Vars.input_mode with
| Navigate -> (
match key with
| (`ASCII 'C', [`Ctrl])
| (`ASCII 'Q', [`Ctrl]) -> (
Lwd.set UI_base.Vars.quit true;
UI_base.Vars.action := None;
`Handled
)
| (`ASCII '<', []) -> (
UI_base.Key_binding_info.decr_rotation ();
`Handled
)
| (`ASCII '>', []) -> (
UI_base.Key_binding_info.incr_rotation ();
`Handled
)
| (`ASCII ' ', []) -> (
let index = Lwd.peek UI_base.Vars.index_of_document_selected in
if index < Array.length search_result_groups then (
let doc, _ = search_result_groups.(index) in
toggle_mark ~path:(Document.path doc)
);
`Handled
)
| (`ASCII 'm', []) -> (
UI_base.set_input_mode Mark;
`Handled
)
| (`ASCII 'M', []) -> (
UI_base.set_input_mode Unmark;
`Handled
)
| (`ASCII 'd', []) -> (
UI_base.set_input_mode Drop;
`Handled
)
| (`ASCII 'n', []) -> (
UI_base.set_input_mode Narrow;
`Handled
)
| (`ASCII 'r', []) -> (
UI_base.set_input_mode Reload;
`Handled
)
| (`ASCII 'y', []) -> (
UI_base.set_input_mode Copy;
`Handled
)
| (`ASCII 'Y', []) -> (
UI_base.set_input_mode Copy_paths;
`Handled
)
| (`Arrow `Left, [])
| (`ASCII 'u', [])
| (`ASCII 'Z', [`Ctrl]) -> (
Session_manager.shift_ver ~offset:(-1);
`Handled
)
| (`Arrow `Right, [])
| (`ASCII 'R', [`Ctrl])
| (`ASCII 'Y', [`Ctrl]) -> (
Session_manager.shift_ver ~offset:1;
`Handled
)
| (`Tab, [])
| (`Tab, [`Shift]) -> (
let direction =
match key with
| (_, [`Shift]) -> `Expand_left
| (_, _) -> `Expand_right
in
Session_manager.update_from_cur_snapshot
(fun cur_snapshot ->
let state = Session.Snapshot.state cur_snapshot in
let cur = Session.State.screen_split state in
let offset =
match direction with
| `Expand_left -> 1
| `Expand_right -> -1
in
let next =
Command.screen_split_of_int
(Command.int_of_screen_split cur + offset)
in
let command = `Split_screen next in
state
|> Session.run_command
(UI_base.task_pool ())
command
|> Option.get
|> (fun (command, state) ->
Session.Snapshot.make
~last_command:(Some command)
state)
);
`Handled
)
| (`ASCII '?', [])
| (`ASCII 'v', []) -> (
let pane =
match key with
| (`ASCII '?', []) -> `Key_binding_info
| (`ASCII 'v', []) -> `Bottom_right
| _ -> failwith "unexpected case"
in
Session_manager.update_from_cur_snapshot
(fun cur_snapshot ->
let state = Session.Snapshot.state cur_snapshot in
let cur = Session.State.show_pane state pane in
let command =
if cur then (
`Hide_pane pane
) else (
`Show_pane pane
) in
state
|> Session.run_command
(UI_base.task_pool ())
command
|> Option.get
|> (fun (command, state) ->
Session.Snapshot.make
~last_command:(Some command)
state)
);
`Handled
)
| (`ASCII '=', []) -> (
UI_base.incr_content_view_offset ();
`Handled
)
| (`ASCII '-', []) -> (
UI_base.decr_content_view_offset ();
`Handled
)
| (`Page `Down, [`Shift])
| (`ASCII 'J', [])
| (`Arrow `Down, [`Shift]) -> (
UI_base.set_search_result_selected
~choice_count:search_result_choice_count
(search_result_current_choice+1);
`Handled
)
| (`Page `Up, [`Shift])
| (`ASCII 'K', [])
| (`Arrow `Up, [`Shift]) -> (
UI_base.set_search_result_selected
~choice_count:search_result_choice_count
(search_result_current_choice-1);
`Handled
)
| (`Page `Down, [])
| (`ASCII 'j', [])
| (`Arrow `Down, []) -> (
if document_count = 1 then (
UI_base.set_search_result_selected
~choice_count:search_result_choice_count
(search_result_current_choice+1);
`Handled
) else (
UI_base.set_document_selected
~choice_count:document_count
(document_current_choice+1);
`Handled
)
)
| (`Page `Up, [])
| (`ASCII 'k', [])
| (`Arrow `Up, []) -> (
if document_count = 1 then (
UI_base.set_search_result_selected
~choice_count:search_result_choice_count
(search_result_current_choice-1);
`Handled
) else (
UI_base.set_document_selected
~choice_count:document_count
(document_current_choice-1);
`Handled
)
)
| (`ASCII 'g', []) -> (
UI_base.set_document_selected
~choice_count:document_count
0;
`Handled
)
| (`ASCII 'G', []) -> (
UI_base.set_document_selected
~choice_count:document_count
(document_count - 1);
`Handled
)
| (`ASCII 'f', []) -> (
Nottui.Focus.request UI_base.Vars.filter_field_focus_handle;
UI_base.set_input_mode Filter;
`Handled
)
| (`ASCII 'F', []) -> (
Nottui.Focus.request Vars.path_fuzzy_rank_field_focus_handle;
Lwd.set Vars.path_fuzzy_rank_field UI_base.empty_text_field;
UI_base.set_input_mode Path_fuzzy_rank;
`Handled
)
| (`ASCII '/', []) -> (
Nottui.Focus.request UI_base.Vars.search_field_focus_handle;
UI_base.set_input_mode Search;
`Handled
)
| (`ASCII 'h', []) -> (
Lwd.set UI_base.Vars.quit true;
UI_base.Vars.action := Some UI_base.Edit_command_history;
`Handled
)
| (`ASCII 'S', [`Ctrl]) -> (
UI_base.set_input_mode Save_script;
refresh_script_files ();
Nottui.Focus.request Vars.script_name_field_focus_handle;
`Handled
)
| (`ASCII 'O', [`Ctrl]) -> (
UI_base.set_input_mode Scripts;
refresh_script_files ();
Nottui.Focus.request Vars.script_name_field_focus_handle;
`Handled
)
| (`ASCII 'x', []) -> (
UI_base.set_input_mode Clear;
`Handled
)
| (`ASCII 'l', []) -> (
UI_base.set_input_mode Links;
if search_result_choice_count > 0 then (
let (doc, search_results) = Option.get search_result_group in
let search_result = search_results.(search_result_current_choice) in
let links = Document.links doc in
let avg_pos =
List.fold_left (fun min_max_pos search_result ->
let { Search_result.found_word_pos; _ } = search_result in
match min_max_pos with
| None -> Some (found_word_pos, found_word_pos)
| Some (min_pos, max_pos) -> (
Some (min found_word_pos min_pos,
max found_word_pos max_pos)
)
)
None
(Search_result.found_phrase search_result)
|> (fun x ->
let (x, y) = Option.get x in
(x + y) / 2)
in
let before, exact, after = Int_map.split avg_pos (Document.link_index_of_start_pos doc) in
let index =
match exact with
| Some index -> Some index
| None -> (
match
Int_map.max_binding_opt before,
Int_map.min_binding_opt after
with
| Some (pos_x, index_x), Some (pos_y, index_y) -> (
let diff_x = Int.to_float (Int.abs (pos_x - avg_pos)) in
let diff_y = Int.to_float (Int.abs (pos_y - avg_pos)) in
(* We prefer picking y (link after search result)
over x (link before search result), as it usually feels more
intuitive to jump forward than backward.
But if distance to x is <= 50% the distance
to y, then we resort to x.
*)
if diff_x /. diff_y <= 0.5 then (
Some index_x
) else (
let link_x = links.(index_x) in
let end_inc_pos_x = link_x.Link.end_inc_pos in
if pos_x <= avg_pos && avg_pos <= end_inc_pos_x then (
Some index_x
) else (
Some index_y
)
)
)
| Some (_pos, index), None
| None, Some (_pos, index) -> Some index
| None, None -> None
)
in
match index with
| None -> ()
| Some index -> (
UI_base.set_link_selected
~choice_count:link_choice_count
index
)
);
`Handled
)
| (`ASCII 's', []) -> (
UI_base.set_input_mode (Sort `Asc);
`Handled
)
| (`ASCII 'S', []) -> (
UI_base.set_input_mode (Sort `Desc);
`Handled
)
| (`Enter, []) -> (
Option.iter (fun (doc, search_results) ->
let search_result =
if search_result_current_choice < Array.length search_results then
Some search_results.(search_result_current_choice)
else
None
in
Lwd.set UI_base.Vars.quit true;
UI_base.Vars.action :=
Some (UI_base.Open_file_and_search_result (doc, search_result));
)
search_result_group;
`Handled
)
| _ -> `Handled
)
| Sort order -> (
let exit =
match key with
| (`Escape, []) -> true
| (`ASCII 's', []) -> (
sort (`Score, order);
true
)
| (`ASCII 'p', []) -> (
sort (`Path, order);
true
)
| (`ASCII 'd', []) -> (
sort (`Path_date, order);
true
)
| (`ASCII 'm', []) -> (
sort (`Mod_time, order);
true
)
| _ -> false
in
if exit then (
UI_base.set_input_mode Navigate;
);
`Handled
)
| Clear -> (
let exit =
match key with
| (`Escape, []) -> true
| (`ASCII '/', []) -> (
Lwd.set UI_base.Vars.search_field UI_base.empty_text_field;
update_search ~commit:true ();
true
)
| (`ASCII 'f', []) -> (
Lwd.set UI_base.Vars.filter_field UI_base.empty_text_field;
update_filter ~commit:true ();
true
)
| (`ASCII 'h', []) -> (
Lwd.set UI_base.Vars.quit true;
UI_base.Vars.action := Some UI_base.Clear_command_history;
true
)
| _ -> false
in
if exit then (
UI_base.set_input_mode Navigate;
);
`Handled
)
| Drop -> (
let exit =
match key with
| (`Escape, []) -> true
| (`ASCII '>', []) -> (
UI_base.Key_binding_info.incr_rotation ();
false
)
| (`ASCII 'd', []) -> (
Option.iter (fun (doc, _search_results) ->
drop ~document_count (`Path (Document.path doc))
) search_result_group;
true
)
| (`ASCII 'D', []) -> (
Option.iter (fun (doc, _search_results) ->
drop ~document_count (`All_except (Document.path doc))
) search_result_group;
true
)
| (`ASCII 'l', []) -> (
drop ~document_count `Listed;
true
)
| (`ASCII 'L', []) -> (
drop ~document_count `Unlisted;
true
)
| (`ASCII 'm', []) -> (
drop ~document_count `Marked;
true
)
| (`ASCII 'M', []) -> (
drop ~document_count `Unmarked;
true
)
| _ -> false
in
if exit then (
UI_base.set_input_mode Navigate;
);
`Handled
)
| Mark -> (
let exit =
match key with
| (`Escape, []) -> true
| (`ASCII '>', []) -> (
UI_base.Key_binding_info.incr_rotation ();
false
)
| (`ASCII 'l', []) -> (
mark `Listed;
true
)
| _ -> false
in
if exit then (
UI_base.set_input_mode Navigate;
);
`Handled
)
| Unmark -> (
let exit =
match key with
| (`Escape, []) -> true
| (`ASCII '>', []) -> (
UI_base.Key_binding_info.incr_rotation ();
false
)
| (`ASCII 'l', []) -> (
unmark `Listed;
true
)
| (`ASCII 'a', []) -> (
unmark `All;
true
)
| _ -> false
in
if exit then (
UI_base.set_input_mode Navigate;
);
`Handled
)
| Narrow -> (
let exit =
match key with
| (`Escape, []) -> true
| (`ASCII '>', []) -> (
UI_base.Key_binding_info.incr_rotation ();
false
)
| (`ASCII c, []) -> (
let code_0 = Char.code '0' in
let code_9 = Char.code '9' in
let code_c = Char.code c in
if code_0 <= code_c && code_c <= code_9 then (
let level = code_c - code_0 in
narrow_search_scope_to_level ~level;
true
) else (
false
)
)
| _ -> false
in
if exit then (
UI_base.set_input_mode Navigate;
);
`Handled
)
| Copy -> (
let copy_search_result_groups (s : Session.search_result_group Seq.t) =
Clipboard.pipe_to_clipboard (fun oc ->
Printers.search_result_groups
~color:false
~underline:true
oc
s
)
in
let copy_search_result_group x =
copy_search_result_groups (Seq.return x)
in
let exit =
match key with
| (`Escape, []) -> true
| (`ASCII '>', []) -> (
UI_base.Key_binding_info.incr_rotation ();
false
)
| (`ASCII 'y', []) -> (
Option.iter (fun (doc, search_results) ->
copy_search_result_group
(doc,
(if search_result_current_choice < Array.length search_results then
[|search_results.(search_result_current_choice)|]
else
[||])
)
)
search_result_group;
true
)
| (`ASCII 'a', []) -> (
Option.iter
copy_search_result_group
search_result_group;
true
)
| (`ASCII 'm', []) -> (
let marked =
Session.State.marked_document_paths session_state
in
search_result_groups
|> Array.to_seq
|> Seq.filter (fun (doc, _) ->
String_set.mem (Document.path doc) marked)
|> copy_search_result_groups;
true
)
| (`ASCII 'l', []) -> (
search_result_groups
|> Array.to_seq
|> copy_search_result_groups;
true
)
| _ -> false
in
if exit then (
UI_base.set_input_mode Navigate;
);
`Handled
)
| Copy_paths -> (
let copy_paths s =
Clipboard.pipe_to_clipboard (fun oc ->
Seq.iter (Printers.path_image ~color:false oc) s
)
in
let exit =
match key with
| (`Escape, []) -> true
| (`ASCII '>', []) -> (
UI_base.Key_binding_info.incr_rotation ();
false
)
| (`ASCII 'y', []) -> (
Option.iter (fun (doc, _search_results) ->
copy_paths (Seq.return (Document.path doc))
)
search_result_group;
true
)
| (`ASCII 'm', []) -> (
String_set.inter
(Session.State.usable_document_paths session_state)
(Session.State.marked_document_paths session_state)
|> String_set.to_seq
|> copy_paths;
true
)
| (`ASCII 'M', []) -> (
String_set.diff
(Session.State.usable_document_paths session_state)
(Session.State.marked_document_paths session_state)
|> String_set.to_seq
|> copy_paths;
true
)
| (`ASCII 'l', []) -> (
Session.State.usable_document_paths session_state
|> String_set.to_seq
|> copy_paths;
true
)
| (`ASCII 'L', []) -> (
Session.State.unusable_document_paths session_state
|> copy_paths;
true
)
| _ -> false
in
if exit then (
UI_base.set_input_mode Navigate;
);
`Handled
)
| Reload -> (
let exit =
(match key with
| (`Escape, []) -> true
| (`ASCII '>', []) -> (
UI_base.Key_binding_info.incr_rotation ();
false
)
| (`ASCII 'r', []) -> (
reload_document_selected ~search_result_groups;
true
)
| (`ASCII 'a', []) -> (
UI_base.reset_document_selected ();
Lwd.set UI_base.Vars.quit true;
UI_base.Vars.action := Some UI_base.Recompute_document_src;
true
)
| _ -> false
);
in
if exit then (
UI_base.set_input_mode Navigate;
);
`Handled
)
| Links -> (
let doc_and_link =
if link_choice_count > 0 then (
Option.map (fun (doc, _search_results) ->
(doc, (Document.links doc).(link_current_choice))
) search_result_group
) else (
None
)
in
let set_action_to_open_link () =
Option.iter (fun (doc, link) ->
Lwd.set UI_base.Vars.quit true;
UI_base.Vars.action :=
Some (UI_base.Open_link (doc, link))
) doc_and_link
in
let exit =
(match key with
| (`Escape, []) -> true
| (`Enter, []) -> (
set_action_to_open_link ();
true
)
| (`ASCII 'o', []) -> (
set_action_to_open_link ();
false
)
| (`Page `Down, [])
| (`ASCII 'j', [])
| (`Arrow `Down, []) -> (
UI_base.set_link_selected
~choice_count:link_choice_count
(link_current_choice+1);
false
)
| (`Page `Up, [])
| (`ASCII 'k', [])
| (`Arrow `Up, []) -> (
UI_base.set_link_selected
~choice_count:link_choice_count
(link_current_choice-1);
false
)
| (`ASCII 'y', []) -> (
Option.iter (fun (_doc, link) ->
Clipboard.pipe_to_clipboard (fun oc ->
output_string oc link.Link.link
);
) doc_and_link;
true
)
| _ -> false
);
in
if exit then (
UI_base.set_input_mode Navigate;
);
`Handled
)
| Save_script_overwrite script_name -> (
(match key with
| (`Escape, [])
| (`ASCII 'n', []) -> (
UI_base.set_input_mode Navigate;
)
| (`ASCII 'y', []) -> (
let path = compute_save_script_path script_name in
save_script ~path;
UI_base.set_input_mode (Save_script_edit script_name);
)
| _ -> ()
);
`Handled
)
| Save_script_no_name -> (
let exit =
(match key with
| (`Enter, []) -> true
| _ -> false
);
in
if exit then (
UI_base.set_input_mode Navigate;
);
`Handled
)
| Save_script_edit script_name -> (
let exit =
(match key with
| (`Escape, [])
| (`ASCII 'n', []) -> true
| (`ASCII 'y', []) -> (
let path = compute_save_script_path script_name in
Lwd.set UI_base.Vars.quit true;
UI_base.Vars.action := Some (UI_base.Edit_script path);
true
)
| _ -> false
);
in
if exit then (
UI_base.set_input_mode Navigate;
);
`Handled
)
| Delete_script_confirm (_script, path) -> (
(match key with
| (`Escape, [])
| (`ASCII 'n', []) -> (
UI_base.set_input_mode Scripts;
)
| (`ASCII 'y', []) -> (
Sys.remove path;
refresh_script_files ();
UI_base.set_input_mode Scripts;
)
| _ -> ()
);
`Handled
)
| _ -> `Unhandled
let main : Nottui.ui Lwd.t =
let$* (_, snapshot) =
Session_manager.cur_snapshot
in
let session_state =
Session.Snapshot.state snapshot
in
let search_result_groups =
Session.State.search_result_groups session_state
in
let document_count = Array.length search_result_groups in
UI_base.set_document_selected
~choice_count:document_count
(Lwd.peek UI_base.Vars.index_of_document_selected);
if document_count > 0 then (
UI_base.set_search_result_selected
~choice_count:(Array.length
(snd search_result_groups.(Lwd.peek UI_base.Vars.index_of_document_selected)))
(Lwd.peek UI_base.Vars.index_of_search_result_selected)
);
if document_count > 0 then (
UI_base.set_link_selected
~choice_count:(search_result_groups.(Lwd.peek UI_base.Vars.index_of_document_selected)
|> fst
|> Document.links
|> Array.length)
(Lwd.peek UI_base.Vars.index_of_link_selected)
);
let$* (term_width, term_height) = Lwd.get UI_base.Vars.term_width_height in
let show_key_binding_info_pane = Session.State.show_pane session_state `Key_binding_info in
let$* bottom_pane =
Bottom_pane.main
~width:term_width
~search_result_groups
~show_key_binding_info_pane
in
let bottom_pane_height = Nottui.Ui.layout_height bottom_pane in
let top_pane_height = term_height - bottom_pane_height in
let screen_split = Session.State.screen_split session_state in
let show_bottom_right_pane = Session.State.show_pane session_state `Bottom_right in
let$* top_pane =
Top_pane.main
~width:term_width
~height:top_pane_height
~documents_marked:(Session.State.marked_document_paths session_state)
~screen_split
~show_bottom_right_pane
~search_result_groups
in
Nottui_widgets.vbox
[
Lwd.return (Nottui.Ui.keyboard_area
(keyboard_handler ~session_state ~search_result_groups)
top_pane);
Lwd.return bottom_pane;
]
================================================
FILE: bin/UI_base.ml
================================================
open Docfd_lib
open Lwd_infix
type input_mode =
| Navigate
| Search
| Filter
| Clear
| Sort of [ `Asc | `Desc ]
| Drop
| Mark
| Unmark
| Narrow
| Copy
| Copy_paths
| Reload
| Save_script
| Save_script_overwrite of string
| Save_script_no_name
| Save_script_edit of string
| Scripts
| Delete_script_confirm of string * string
| Links
| Path_fuzzy_rank
[@@deriving ord]
module Input_mode_map = Map.Make (struct
type t = input_mode
let compare x y =
match x, y with
| Save_script_overwrite _, Save_script_overwrite _ -> 0
| Save_script_edit _, Save_script_edit _ -> 0
| Delete_script_confirm _, Delete_script_confirm _ -> 0
| _, _ -> compare_input_mode x y
end)
type top_level_action =
| Recompute_document_src
| Open_file_and_search_result of Document.t * Search_result.t option
| Open_link of (Document.t * Link.t)
| Clear_command_history
| Edit_command_history
| Open_script of string
| Edit_script of string
type search_status = [
| `Idle
| `Searching
| `Parse_error
]
type filter_status = [
| `Idle
| `Filtering
| `Parse_error
]
let empty_text_field = ("", 0)
let render_mode_of_document (doc : Document.t)
: Content_and_search_result_rendering.render_mode =
match File_utils.format_of_file (Document.path doc) with
| `PDF -> `Page_num_only
| `Pandoc_supported_format -> `None
| `Text | `Other -> `Line_num_only
module Vars = struct
let quit = Lwd.var false
let pool : Task_pool.t option Atomic.t = Atomic.make None
let action : top_level_action option ref = ref None
let eio_env : Eio_unix.Stdenv.base option ref = ref None
let hide_document_list : bool Lwd.var = Lwd.var false
let input_mode : input_mode Lwd.var = Lwd.var Navigate
let document_src : Document_src.t ref = ref (Document_src.(Files empty_file_collection))
let term : Notty_unix.Term.t option ref = ref None
let term_width_height : (int * int) Lwd.var = Lwd.var (0, 0)
let content_view_offset = Lwd.var 0
let autocomplete_choices = Lwd.var []
let filter_field = Lwd.var empty_text_field
let filter_field_focus_handle = Nottui.Focus.make ()
let search_field = Lwd.var empty_text_field
let search_field_focus_handle = Nottui.Focus.make ()
let search_ui_status : search_status Lwd.var = Lwd.var `Idle
let filter_ui_status : filter_status Lwd.var = Lwd.var `Idle
let index_of_document_selected = Lwd.var 0
let index_of_search_result_selected = Lwd.var 0
let index_of_link_selected = Lwd.var 0
let index_of_script_selected = Lwd.var 0
end
let reset_content_view_offset () =
Lwd.set Vars.content_view_offset 0
let decr_content_view_offset () =
let x = Lwd.peek Vars.content_view_offset in
Lwd.set Vars.content_view_offset (x - 1)
let incr_content_view_offset () =
let x = Lwd.peek Vars.content_view_offset in
Lwd.set Vars.content_view_offset (x + 1)
let reset_document_selected () =
reset_content_view_offset ();
Lwd.set Vars.index_of_document_selected 0;
Lwd.set Vars.index_of_search_result_selected 0;
Lwd.set Vars.index_of_link_selected 0
let set_document_selected ~choice_count n =
let n = Misc_utils.bound_selection ~choice_count n in
let old = Lwd.peek Vars.index_of_document_selected in
if old <> n then (
reset_content_view_offset ();
Lwd.set Vars.index_of_document_selected n;
Lwd.set Vars.index_of_search_result_selected 0;
Lwd.set Vars.index_of_link_selected 0;
)
let set_search_result_selected ~choice_count n =
let old = Lwd.peek Vars.index_of_search_result_selected in
if old <> n then (
reset_content_view_offset ();
let n = Misc_utils.bound_selection ~choice_count n in
Lwd.set Vars.index_of_search_result_selected n
)
let set_link_selected ~choice_count n =
let old = Lwd.peek Vars.index_of_link_selected in
if old <> n then (
reset_content_view_offset ();
let n = Misc_utils.bound_selection ~choice_count n in
Lwd.set Vars.index_of_link_selected n
)
let set_script_selected ~choice_count n =
let old = Lwd.peek Vars.index_of_script_selected in
if old <> n then (
let n = Misc_utils.bound_selection ~choice_count n in
Lwd.set Vars.index_of_script_selected n
)
let task_pool () =
Option.get (Atomic.get Vars.pool)
let eio_env () =
Option.get !Vars.eio_env
let term () =
Option.get !Vars.term
let full_term_sized_background =
let$ (term_width, term_height) = Lwd.get Vars.term_width_height in
Notty.I.void term_width term_height
|> Nottui.Ui.atom
let vbar ~height =
let uc = Uchar.of_int 0x2502 in
Notty.I.uchar Notty.A.(fg white) uc 1 height
|> Nottui.Ui.atom
let hbar ~width =
let uc = Uchar.of_int 0x2015 in
Notty.I.uchar Notty.A.(fg white) uc width 1
|> Nottui.Ui.atom
let hpane
~l_ratio
~width
~height
(x : width:int -> Nottui.ui Lwd.t)
(y : width:int -> Nottui.ui Lwd.t)
: Nottui.ui Lwd.t =
let l_width =
(* Minus 1 for pane separator bar. *)
Int.to_float width *. l_ratio
|> Float.floor
|> Int.of_float
|> (fun x ->
if x = 0 || x = width then (
x
) else (
x - 1
))
in
let r_width =
(* Minus 1 here too just to be conservative. *)
width - l_width - 1
in
let crop w x = Nottui.Ui.resize ~w ~h:height x in
let x () =
let$ x = x ~width:l_width in
crop l_width x
in
let y () =
let$ y = y ~width:r_width in
crop r_width y
in
if l_width = 0 then (
y ()
) else if r_width = 0 then (
x ()
) else (
let$* x = x () in
let$ y = y () in
Nottui.Ui.hcat [
x;
vbar ~height;
y;
]
)
let vpane
~width
~height
(x : height:int -> Nottui.ui Lwd.t)
(y : height:int -> Nottui.ui Lwd.t)
: Nottui.ui Lwd.t =
let t_height =
(Misc_utils.div_round_up height 2)
in
let b_height =
(* Minus 1 for pane separator bar. *)
(height / 2) - 1
in
let$* x = x ~height:t_height in
let$ y = y ~height:b_height in
let crop h x = Nottui.Ui.resize ~w:width ~h x in
Nottui.Ui.vcat [
crop t_height x;
hbar ~width;
crop b_height y;
]
let (mini, maxi, clampi) = Lwd_utils.(mini, maxi, clampi)
(* Modified from upstream Nottui source code. *)
let edit_field
~focus
~on_change
~on_submit
~on_cancel
?(on_tab : ((string * int) -> unit) option)
?(on_up_down : ([ `Up | `Down ] -> (string * int) -> unit) option)
?(on_ctrl_prefixed : (Nottui.Ui.key -> (string * int) -> [ `Handled | `Unhandled ]) option)
state
=
let update _focus_h focus (text, pos) =
let pos = clampi pos ~min:0 ~max:(String.length text) in
let content =
Nottui.Ui.atom @@ Notty.I.hcat @@
if Nottui.Focus.has_focus focus then (
let attr = Notty.A.(bg lightblue) in
let len = String.length text in
(if pos >= len
then [Notty.I.string attr text]
else [Notty.I.string attr (String.sub text 0 pos)])
@
(if pos < String.length text then
[Notty.I.string Notty.A.(bg lightred) (String.sub text pos 1);
Notty.I.string attr (String.sub text (pos + 1) (len - pos - 1))]
else [Notty.I.string Notty.A.(bg lightred) " "]);
) else
[Notty.I.string Notty.A.(st underline) (if text = "" then " " else text)]
in
let handler = function
| `Escape, [] -> (
on_cancel (text, pos);
`Handled
)
| `ASCII k, [] -> (
let text =
if pos < String.length text then (
String.sub text 0 pos ^ String.make 1 k ^
String.sub text pos (String.length text - pos)
) else (
text ^ String.make 1 k
)
in
on_change (text, (pos + 1));
`Handled
)
| `Backspace, _ -> (
let text =
if pos > 0 then (
if pos < String.length text then (
String.sub text 0 (pos - 1) ^
String.sub text pos (String.length text - pos)
) else if String.length text > 0 then (
String.sub text 0 (String.length text - 1)
) else text
) else text
in
let pos = maxi 0 (pos - 1) in
on_change (text, pos);
`Handled
)
| `Enter, _ -> (
on_submit (text, pos);
`Handled
)
| `Arrow `Left, [] -> (
let pos = mini (String.length text) pos in
if pos > 0 then (
on_change (text, pos - 1);
`Handled
)
else `Unhandled
)
| `Arrow `Right, [] -> (
let pos = pos + 1 in
if pos <= String.length text
then (on_change (text, pos); `Handled)
else `Unhandled
)
| (`Arrow (`Up as ud), [])
| (`Arrow (`Down as ud), []) -> (
match on_up_down with
| None -> `Unhandled
| Some on_up_down -> (
on_up_down ud (text, pos);
`Handled
)
)
| (`Tab, []) -> (
match on_tab with
| None -> `Unhandled
| Some on_tab -> (
on_tab (text, pos);
`Handled
)
)
| (_, [`Ctrl]) as x -> (
match on_ctrl_prefixed with
| None -> `Unhandled
| Some f -> (
f x (text, pos)
)
)
| _ -> `Unhandled
in
Nottui.Ui.keyboard_area ~focus handler content
in
let state = Lwd.get state in
let node =
Lwd.map2 ~f:(update focus) (Nottui.Focus.status focus) state
in
let mouse_grab (text, pos) ~x ~y:_ = function
| `Left ->
if x <> pos then on_change (text, x);
Nottui.Focus.request focus;
`Handled
| _ -> `Unhandled
in
Lwd.map2 state node ~f:(fun state content ->
Nottui.Ui.mouse_area (mouse_grab state) content
)
let mouse_handler
~(f : [ `Up | `Down ] -> unit)
~x ~y
(button : Notty.Unescape.button)
=
let _ = x in
let _ = y in
match button with
| `Scroll `Down -> (
f `Down;
`Handled
)
| `Scroll `Up -> (
f `Up;
`Handled
)
| _ -> `Unhandled
module Content_view = struct
let main
~(input_mode : input_mode)
~height
~width
~(search_result_group : Document.t * Search_result.t array)
~(search_result_selected : int)
~(link_selected : int)
: Nottui.ui Lwd.t =
let (document, search_results) = search_result_group in
let links = Document.links document in
let data =
let search_result_count = Array.length search_results in
let link_count = Array.length links in
match input_mode with
| Links -> (
if link_count = 0 then (
None
) else (
Some (`Link links.(link_selected))
)
)
| _ -> (
if search_result_count = 0 then (
None
) else (
Some (`Search_result search_results.(search_result_selected))
)
)
in
let$* _ = Lwd.get Vars.content_view_offset in
let content =
Content_and_search_result_rendering.content_snippet
~doc_id:(Document.doc_id document)
~view_offset:Vars.content_view_offset
?data
~height
~width
()
in
let$* background = full_term_sized_background in
Nottui.Ui.join_z background (Nottui.Ui.atom content)
|> Nottui.Ui.mouse_area
(mouse_handler
~f:(fun direction ->
match direction with
| `Up -> decr_content_view_offset ()
| `Down -> incr_content_view_offset ()
)
)
|> Lwd.return
end
module Status_bar = struct
let fg_color = Notty.A.black
let bg_color = Notty.A.white
let attr = Notty.A.(bg bg_color ++ fg fg_color)
let background_bar : Nottui.Ui.t Lwd.t =
let$ (term_width, _term_height) = Lwd.get Vars.term_width_height in
Notty.I.char Notty.A.(bg bg_color) ' ' term_width 1
|> Nottui.Ui.atom
let element_spacing = 4
let element_spacer =
Notty.(I.string
A.(bg bg_color ++ fg fg_color))
(String.make element_spacing ' ')
let input_mode_images =
let l =
[ (Navigate, "NAVIGATE")
; (Search, "SEARCH")
; (Filter, "FILTER")
; (Clear, "CLEAR")
; (Sort `Asc, "SORT-ASC")
; (Sort `Desc, "SORT-DESC")
; (Drop, "DROP")
; (Mark, "MARK")
; (Unmark, "UNMARK")
; (Narrow, "NARROW")
; (Copy, "COPY")
; (Copy_paths, "COPY-PATHS")
; (Reload, "RELOAD")
; (Save_script, "SAVE-SCRIPT")
; (Save_script_overwrite "", "SAVE-SCRIPT")
; (Save_script_no_name, "SAVE-SCRIPT")
; (Save_script_edit "", "SAVE-SCRIPT")
; (Scripts, "SCRIPTS")
; (Delete_script_confirm ("", ""), "DELETE-SCRIPT")
; (Links, "LINKS")
; (Path_fuzzy_rank, "PATH-FUZZY-RANK")
]
in
let max_input_mode_string_len =
List.fold_left (fun acc (_, s) ->
max acc (String.length s)
)
0
l
in
let input_mode_string_background =
Notty.I.char Notty.A.(bg bg_color) ' ' max_input_mode_string_len 1
in
List.fold_left (fun m (mode, s) ->
let s = Notty.(I.string A.(bg bg_color ++ fg fg_color ++ st bold) s) in
Input_mode_map.add mode Notty.I.(s </> input_mode_string_background) m
)
Input_mode_map.empty
l
end
module Key_binding_info = struct
let rotation : int Lwd.var = Lwd.var 0
let incr_rotation () =
Lwd.set rotation (Lwd.peek rotation + 1)
let decr_rotation () =
Lwd.set rotation (Lwd.peek rotation - 1)
let reset_rotation () =
Lwd.set rotation 0
type labelled_msg = {
label : string;
msg : string;
}
type labelled_msg_line = labelled_msg list
type grid_contents = (input_mode * (labelled_msg_line list)) list
type grid_lookup = Nottui.ui Lwd.t Input_mode_map.t
let grid_lights : (string, Mtime.t ref * bool Lwd.var list) Hashtbl.t = Hashtbl.create 100
let lock = Eio.Mutex.create ()
let grid_light_on_req : string Eio.Stream.t = Eio.Stream.create 100
let grid_light_off_req : (Mtime.t * Mtime.t * string) Eio.Stream.t = Eio.Stream.create 100
let blink label =
Eio.Stream.add grid_light_on_req label
let grid_light_fiber () =
let clock = Eio.Stdenv.mono_clock (eio_env ()) in
Eio.Fiber.both
(fun () ->
while true do
let label = Eio.Stream.take grid_light_on_req in
let ts_now = Eio.Time.Mono.now clock in
Eio.Mutex.use_rw lock ~protect:false (fun () ->
match Hashtbl.find_opt grid_lights label with
| None -> failwith "unexpected case"
| Some (ts, l) -> (
ts := ts_now;
List.iter (fun x -> Lwd.set x true) l;
Eio.Stream.add
grid_light_off_req
(ts_now, Option.get (Mtime.(add_span ts_now Params.blink_on_duration)), label);
)
)
done
)
(fun () ->
while true do
let ts_req_time, ts_target_time, label = Eio.Stream.take grid_light_off_req in
Eio.Time.Mono.sleep_until clock ts_target_time;
Eio.Mutex.use_rw lock ~protect:false (fun () ->
match Hashtbl.find_opt grid_lights label with
| None -> failwith "unexpected case"
| Some (ts_last_update, l) -> (
if Mtime.equal !ts_last_update ts_req_time then (
List.iter (fun x -> Lwd.set x false) l;
)
)
)
done
)
let make_grid_lookup grid_contents : grid_lookup =
let max_label_msg_len_lookup : (input_mode * (int * int) Int_map.t) list =
grid_contents
|> List.map (fun (grid_key, grid) ->
let lookup =
List.fold_left
(fun (acc : (int * int) Int_map.t) (line : labelled_msg_line) ->
line
|> List.to_seq
|> Seq.fold_lefti
(fun (acc : (int * int) Int_map.t) col ({ label; msg } : labelled_msg) ->
let label_len =
Uuseg_string.fold_utf_8 `Grapheme_cluster (fun x _ -> x + 1) 0 label
in
let msg_len =
Uuseg_string.fold_utf_8 `Grapheme_cluster (fun x _ -> x + 1) 0 msg
in
let (max_label_len, max_msg_len) =
match Int_map.find_opt col acc with
| None -> (label_len, msg_len)
| Some (max_label_len, max_msg_len) -> (
(max max_label_len label_len,
max max_msg_len msg_len)
)
in
Int_map.add col (max_label_len, max_msg_len) acc
)
acc
)
Int_map.empty
grid
in
(grid_key, lookup)
)
in
let label_msg_pair grid_key col { label; msg } : Nottui.ui Lwd.t =
let (max_label_len, max_msg_len) =
List.assoc grid_key max_label_msg_len_lookup
|> Int_map.find col
in
let light_on_var = Lwd.var false in
Eio.Mutex.use_rw lock ~protect:false (fun () ->
let x =
match Hashtbl.find_opt grid_lights label with
| None -> (ref Mtime.min_stamp, [ light_on_var ])
| Some (x, l) -> (x, light_on_var :: l)
in
Hashtbl.replace grid_lights label x
);
let$ light_on = Lwd.get light_on_var in
let label_attr =
if light_on then
Notty.A.(fg black ++ bg lightyellow ++ st bold)
else
Notty.A.(fg lightyellow ++ st bold)
in
let msg_attr = Notty.A.empty in
let msg = String.capitalize_ascii msg in
let content = Notty.(I.hcat
[ I.(string label_attr label
</>
(string label_attr (String.make max_label_len ' ')))
; I.string A.empty " "
; I.string msg_attr msg
]
)
in
let full_background =
Notty.I.void (max_label_len + 1 + max_msg_len + 3) 1
in
Notty.I.(content </> full_background)
|> Nottui.Ui.atom
in
List.fold_left (fun m (grid_key, grid_contents) ->
let max_row_size =
List.fold_left (fun n l ->
max n (List.length l)
)
0
grid_contents
in
let grid_contents =
grid_contents
|> List.map (fun l ->
let padding =
List.init (max_row_size - List.length l)
(fun _ -> { label = ""; msg = "" })
in
List.mapi (fun col x ->
label_msg_pair grid_key col x
)
(l @ padding)
)
in
let grid =
let$* rotation = Lwd.get rotation in
grid_contents
|> List.map (fun l ->
Misc_utils.rotate_list
(((rotation mod max_row_size) + max_row_size)
mod
max_row_size
)
l
)
|> Nottui_widgets.grid
~pad:(Nottui.Gravity.make ~h:`Negative ~v:`Negative)
in
Input_mode_map.add grid_key grid m
)
Input_mode_map.empty
grid_contents
let main ~(grid_lookup : grid_lookup) ~(input_mode : input_mode) =
Input_mode_map.find input_mode grid_lookup
end
let filter_bar_label_string = "Document filter"
let search_bar_label_string = "Content search"
let max_label_length =
List.fold_left (fun acc s ->
max acc (String.length s)
)
0
[ filter_bar_label_string
; search_bar_label_string
]
let pad_label_string s =
CCString.pad ~side:`Right ~c:' ' max_label_length s
let autocomplete ~choices (text, pos) : string * int =
let left = String.sub text 0 pos in
let right = String.sub text pos (String.length text - pos) in
let grab_input_word (s : string) =
let rec aux acc i s =
if i < 0 then (
CCString.of_list acc
) else (
let c = s.[i] in
if Parser_components.is_alphanum c
|| c = '-'
|| c = ':'
then (
aux (c :: acc) (i - 1) s
) else (
aux acc (-1) s
)
)
in
aux [] (String.length s - 1) s
in
let current_input_word = grab_input_word left in
let usable_choices =
List.filter
(CCString.prefix ~pre:current_input_word)
choices
in
Lwd.set
Vars.autocomplete_choices usable_choices;
match usable_choices with
| [] -> (text, pos)
| _ -> (
let best_fit = usable_choices
|> List.to_seq
|> String_utils.longest_common_prefix
in
let left =
String.sub
left
0
(String.length left - String.length current_input_word)
in
(String.concat "" [ left; best_fit; right ],
pos + (String.length best_fit - String.length current_input_word))
)
module Filter_bar = struct
let label_string = pad_label_string filter_bar_label_string
let label ~(input_mode : input_mode) =
let attr =
match input_mode with
| Filter -> Notty.A.(st bold)
| _ -> Notty.A.empty
in
Notty.I.string attr label_string
|> Nottui.Ui.atom
|> Lwd.return
let status =
let$* status = Lwd.get Vars.filter_ui_status in
(match status with
| `Idle -> (
Notty.I.string Notty.A.(fg lightgreen)
" OK"
)
| `Filtering -> (
Notty.I.string Notty.A.(fg lightyellow)
" ..."
)
| `Parse_error -> (
Notty.I.string Notty.A.(fg lightred)
" ERR"
)
)
|> Nottui.Ui.atom
|> Lwd.return
let autocomplete_choices =
[ "path-date:"
; "path-fuzzy:"
; "path-glob:"
; "ext:"
; "content:"
; "mod-date:"
]
let main
~input_mode
~(text_field : (string * int) Lwd.var)
~focus_handle
~on_change
~on_submit
: Nottui.ui Lwd.t =
Nottui_widgets.hbox
[
label ~input_mode;
status;
Lwd.return (Nottui.Ui.atom (Notty.I.strf ": "));
edit_field text_field
~focus:focus_handle
~on_cancel:(fun (_text, _x) -> ())
~on_change:(fun (text, x) ->
Lwd.set text_field (text, x);
on_change ();
)
~on_submit:(fun (text, x) ->
Lwd.set text_field (text, x);
on_submit ();
Lwd.set Vars.autocomplete_choices [];
Nottui.Focus.release focus_handle;
Lwd.set Vars.input_mode Navigate
)
~on_tab:(fun (text, pos) ->
let (text, pos) =
autocomplete ~choices:autocomplete_choices (text, pos)
in
Lwd.set text_field (text, pos)
);
]
end
module Search_bar = struct
let label_string = pad_label_string search_bar_label_string
let label ~(input_mode : input_mode) =
let attr =
match input_mode with
| Search -> Notty.A.(st bold)
| _ -> Notty.A.empty
in
Notty.I.string attr label_string
|> Nottui.Ui.atom
|> Lwd.return
let status =
let$* status = Lwd.get Vars.search_ui_status in
(match status with
| `Idle -> (
Notty.I.string Notty.A.(fg lightgreen)
" OK"
)
| `Searching -> (
Notty.I.string Notty.A.(fg lightyellow)
" ..."
)
| `Parse_error -> (
Notty.I.string Notty.A.(fg lightred)
" ERR"
)
)
|> Nottui.Ui.atom
|> Lwd.return
let main
~input_mode
~(text_field : (string * int) Lwd.var)
~focus_handle
~on_change
~on_submit
: Nottui.ui Lwd.t =
Nottui_widgets.hbox
[
label ~input_mode;
status;
Lwd.return (Nottui.Ui.atom (Notty.I.strf ": "));
edit_field text_field
~focus:focus_handle
~on_cancel:(fun (_text, _x) -> ())
~on_change:(fun (text, x) ->
Lwd.set text_field (text, x);
on_change ();
)
~on_submit:(fun (text, x) ->
Lwd.set text_field (text, x);
on_submit ();
Lwd.set Vars.autocomplete_choices [];
Nottui.Focus.release focus_handle;
Lwd.set Vars.input_mode Navigate
)
~on_tab:(fun (_, _) -> ());
]
end
let term' : unit -> Notty_unix.Term.t = term
let ui_loop ~quit ~term root =
let renderer = Nottui.Renderer.make () in
let root =
let$ root = root in
root
(* |> Nottui.Ui.event_filter (fun x ->
match x with
| `Key (`Escape, []) -> (
Lwd.set quit true;
`Handled
)
| _ -> `Unhandled
) *)
in
let rec loop () =
if not (Lwd.peek quit) then (
let (term_width, term_height) = Notty_unix.Term.size (term' ()) in
let (prev_term_width, prev_term_height) = Lwd.peek Vars.term_width_height in
if term_width <> prev_term_width || term_height <> prev_term_height then (
Lwd.set Vars.term_width_height (term_width, term_height)
);
Nottui_unix.step
~process_event:true
~timeout:0.05
~renderer
term
(Lwd.observe @@ root);
Eio.Fiber.yield ();
loop ()
)
in
loop ()
let set_input_mode mode =
Lwd.set Vars.input_mode mode;
Key_binding_info.reset_rotation ()
================================================
FILE: bin/args.ml
================================================
open Cmdliner
open Docfd_lib
open Misc_utils
let no_pdftotext_arg_name = "no-pdftotext"
let no_pdftotext_arg =
let doc =
Fmt.str {|Disable use of pdftotext command.
Files that require use of pdftotext are excluded.
|}
in
Arg.(value & flag & info [ no_pdftotext_arg_name ] ~doc)
let no_pandoc_arg_name = "no-pandoc"
let no_pandoc_arg =
let doc =
Fmt.str {|Disable use of pandoc command.
Files that require use of pandoc are excluded.
|}
in
Arg.(value & flag & info [ no_pandoc_arg_name ] ~doc)
let hidden_arg_name = "hidden"
let hidden_arg =
let doc =
Fmt.str {|Scan hidden files and directories.
By default, hidden files and directories are skipped.
A file or directory is hidden if the base name starts
with a dot, e.g. ".gitignore".
|}
in
Arg.(value & flag & info [ hidden_arg_name ] ~doc)
let max_depth_arg_name = "max-depth"
let max_depth_arg =
let doc =
Fmt.str
"Scan up to N levels when exploring file trees.
This applies to directory paths provided
and ** in globs.
Note that --%s 0 results in no-op when scanning
directories, and --%s 1 means only scanning for
direct children."
max_depth_arg_name
max_depth_arg_name
in
Arg.(
value
& opt int Params.default_max_file_tree_scan_depth
& info [ max_depth_arg_name ] ~doc ~docv:"N"
)
let exts_arg_name = "exts"
let exts_arg =
let doc =
"File extensions to use, comma separated. Leading dots of any extension are removed."
in
Arg.(
value
& opt string Params.default_recognized_exts
& info [ exts_arg_name ] ~doc ~docv:"EXTS"
)
let single_file_exts_arg_name = Fmt.str "single-line-%s" exts_arg_name
let single_line_exts_arg =
let doc =
Fmt.str "Same as --%s, but use single line search mode instead.
If an extension appears in both --%s and --%s,
then single line search mode is used for that extension."
exts_arg_name
exts_arg_name
single_file_exts_arg_name
in
Arg.(
value
& opt string Params.default_recognized_single_line_exts
& info [ single_file_exts_arg_name ] ~doc ~docv:"EXTS"
)
let add_exts_arg_name = "add-exts"
let add_exts_arg =
let doc =
"Additional file extensions to use, comma separated.
May be specified multiple times."
in
Arg.(
value
& opt_all string []
& info [ add_exts_arg_name ] ~doc ~docv:"EXTS"
)
let single_line_add_exts_arg_name = Fmt.str "single-line-%s" add_exts_arg_name
let single_line_add_exts_arg =
let doc =
Fmt.str "Same as --%s, but use single line search mode instead." add_exts_arg_name
in
Arg.(
value
& opt_all string []
& info [ single_line_add_exts_arg_name ] ~doc ~docv:"EXTS"
)
let max_fuzzy_edit_dist_arg_name = "max-fuzzy-edit-dist"
let max_fuzzy_edit_dist_arg =
let doc =
"Maximum edit distance for fuzzy matches."
in
Arg.(
value
& opt int Params.default_max_fuzzy_edit_dist
& info [ max_fuzzy_edit_dist_arg_name ] ~doc ~docv:"N"
)
let max_token_search_dist_arg_name = "max-token-search-dist"
let max_token_search_dist_arg =
let doc =
"Maximum distance to look for the next matching token in document.
If two tokens are adjacent, then they are 1 distance away from each other.
Note that contiguous spaces count as one token as well."
in
Arg.(
value
& opt int Params.default_max_token_search_dist
& info [ max_token_search_dist_arg_name ] ~doc ~docv:"N"
)
let max_linked_token_search_dist_arg_name = "max-linked-token-search-dist"
let max_linked_token_search_dist_arg =
let doc =
Fmt.str
{|Similar to %s but for linked tokens.
Two tokens are linked if there is no space between them in the search phrase,
e.g. "-" and ">" are linked in "->" but not in "- >",
"and" "/" "or" are linked in "and/or" but not in "and / or".|}
max_token_search_dist_arg_name
in
Arg.(
value
& opt int Params.default_max_linked_token_search_dist
& info [ max_linked_token_search_dist_arg_name ] ~doc ~docv:"N"
)
let tokens_per_search_scope_level_arg_name = "tokens-per-search-scope-level"
let tokens_per_search_scope_level_arg =
let doc =
Fmt.str
{|Number of tokens to use around the current search
results for each search scope level in narrow mode.|}
in
Arg.(
value
& opt int Params.default_tokens_per_search_scope_level
& info [ tokens_per_search_scope_level_arg_name ] ~doc ~docv:"N"
)
let index_chunk_size_arg_name = "index-chunk-size"
let index_chunk_size_arg =
let doc =
"Number of tokens to send as a job unit to the thread pool for indexing."
in
Arg.(
value
& opt int Params.default_index_chunk_size
& info [ index_chunk_size_arg_name ] ~doc ~docv:"N"
)
let cache_dir_arg =
let doc =
"Docfd cache directory, mainly for index DB."
in
let cache_home = Xdg_utils.cache_home in
Arg.(
value
& opt string (Filename.concat cache_home "docfd")
& info [ "cache-dir" ] ~doc ~docv:"DIR"
)
let cache_limit_arg_name = "cache-limit"
let cache_limit_arg =
let doc =
"Maximum number of documents to keep in index.
Docfd resets the cache to this limit at launch."
in
Arg.(
value
& opt int Params.default_cache_limit
& info [ cache_limit_arg_name ] ~doc ~docv:"N"
)
let data_dir_arg =
let doc =
"Docfd data directory."
in
let data_home = Xdg_utils.data_home in
Arg.(
value
& opt string (Filename.concat data_home "docfd")
& info [ "data-dir" ] ~doc ~docv:"DIR"
)
let index_only_arg =
let doc =
Fmt.str "Exit after indexing."
in
Arg.(value & flag & info [ "index-only" ] ~doc)
let debug_log_arg =
let doc =
Fmt.str "Specify debug log file to use and enable debug mode where
additional checks are enabled and additional info is displayed on UI.
If FILE is -, then debug log is printed to stderr instead.
Otherwise FILE is opened in append mode for log writing."
in
Arg.(
value
& opt (some string) None
& info [ "debug-log" ] ~doc ~docv:"FILE"
)
let start_with_filter_arg_name = "start-with-filter"
let start_with_filter_arg =
let doc =
Fmt.str "Start interactive mode with an initial filter using expression EXP."
in
Arg.(
value
& opt (some string) None
& info [ start_with_filter_arg_name ] ~doc ~docv:"EXP"
)
let start_with_search_arg_name = "start-with-search"
let start_with_search_arg =
let doc =
Fmt.str "Start interactive mode with search expression EXP."
in
Arg.(
value
& opt (some string) None
& info [ start_with_search_arg_name ] ~doc ~docv:"EXP"
)
let sample_arg_name = "sample"
let samples_per_doc_arg_name = "samples-per-doc"
let sample_arg =
let doc =
Fmt.str "Search with expression EXP in non-interactive mode but only
show top N results where N is controlled by --%s."
samples_per_doc_arg_name
in
Arg.(
value
& opt (some string) None
& info [ sample_arg_name ] ~doc ~docv:"EXP"
)
let samples_per_doc_arg =
let doc =
Fmt.str
"Number of search results to show per document when --%s is used
or when samples printing is triggered."
sample_arg_name
in
Arg.(
value
& opt int Params.default_samples_per_document
& info [ samples_per_doc_arg_name ] ~doc ~docv:"N"
)
let search_arg_name = "search"
let search_arg =
let doc =
"Search with expression EXP in non-interactive mode and show all results."
in
Arg.(
value
& opt (some string) None
& info [ search_arg_name ] ~doc ~docv:"EXP"
)
let filter_arg_name = "filter"
let filter_arg =
let doc =
Fmt.str
"Filter with expression EXP in non-interactive mode. May be combined with --%s or --%s."
search_arg_name
sample_arg_name
in
Arg.(
value
& opt (some string) None
& info [ filter_arg_name ] ~doc ~docv:"EXP"
)
let sort_arg_name = "sort"
let sort_arg =
let doc =
Fmt.str
"Sort document by: TYPE,ORDER. TYPE is one of: path, path-date, score, mod-time. ORDER is one of: asc, desc."
in
Arg.(
value
& opt string Params.default_sort_by_arg
& info [ sort_arg_name ] ~doc ~docv:"TYPE,ORDER"
)
let style_mode_options = [ ("never", `Never); ("always", `Always); ("auto", `Auto) ]
let color_arg =
let doc =
Fmt.str
"Set color mode for search result printing, one of: %s."
(String.concat ", " (List.map fst style_mode_options))
in
Arg.(
value
& opt (Arg.enum style_mode_options) `Auto
& info [ "color" ] ~doc ~docv:"MODE"
)
let underline_arg =
let doc =
Fmt.str
"Set underline mode for search result printing, one of: %s."
(String.concat ", " (List.map fst style_mode_options))
in
Arg.(
value
& opt (Arg.enum style_mode_options) `Auto
& info [ "underline" ] ~doc ~docv:"MODE"
)
let search_result_print_text_width_arg_name = "search-result-print-text-width"
let search_result_print_text_width_arg =
let doc =
"Text width to use when printing search results."
in
Arg.(
value
& opt int Params.default_search_result_print_text_width
& info [ search_result_print_text_width_arg_name ] ~doc ~docv:"N"
)
let search_result_print_snippet_min_size_arg_name = "search-result-print-snippet-min-size"
let search_result_print_snippet_min_size_arg =
let doc =
"If the search result to be printed has fewer than N non-space tokens,
then Docfd tries to add surrounding lines to the snippet
to give better context."
in
Arg.(
value
& opt int Params.default_search_result_print_snippet_min_size
& info [ search_result_print_snippet_min_size_arg_name ] ~doc ~docv:"N"
)
let search_result_print_snippet_max_add_lines_arg_name = "search-result-print-snippet-max-add-lines"
let search_result_print_snippet_max_add_lines_arg =
let doc =
"This controls the maximum number of surrounding lines
Docfd can add in each direction."
in
Arg.(
value
& opt int Params.default_search_result_print_snippet_max_additional_lines_each_direction
& info [ search_result_print_snippet_max_add_lines_arg_name ] ~doc ~docv:"N"
)
let script_arg_name = "script"
let script_arg =
let doc =
Fmt.str "Read and run Docfd script FILE."
in
Arg.(
value
& opt (some string) None
& info [ script_arg_name ] ~doc ~docv:"FILE"
)
let start_with_script_arg_name = "start-with-script"
let start_with_script_arg =
let doc =
Fmt.str "Read and run Docfd script FILE, then continue in interactive mode."
in
Arg.(
value
& opt (some string) None
& info [ start_with_script_arg_name ] ~doc ~docv:"FILE"
)
let paths_from_arg_name = "paths-from"
let paths_from_arg =
let doc =
Fmt.str "Read list of paths from FILES,
which is a comma separated list of files,
and add to the final list of paths to be scanned.
For example, \"--%s path-list0.txt,path-list1.txt\".
If - is in FILES, then stdin is also read for
list of paths to be scanned. This is useful
for piping, e.g. \"find -name '*.txt' | docfd --%s -\""
paths_from_arg_name
paths_from_arg_name
in
Arg.(
value
& opt_all string []
& info [ paths_from_arg_name ] ~doc ~docv:"FILES"
)
let glob_arg_name = "glob"
let glob_arg =
let doc =
"Add to the final list of paths to be scanned using glob pattern.
The pattern should pick up the files directly.
Directories picked up by the pattern are not further scanned
for files with suitable extensions."
in
Arg.(
value
& opt_all string []
& info [ glob_arg_name ] ~doc ~docv:"PATTERN"
)
let single_line_glob_arg_name = Fmt.str "single-line-%s" glob_arg_name
let single_line_glob_arg =
let doc =
Fmt.str
"Same as --%s, but use single line search mode instead.
If the file are picked up by both patterns from --%s and --%s,
then single line search mode is used."
glob_arg_name
glob_arg_name
single_line_glob_arg_name
in
Arg.(
value
& opt_all string []
& info [ single_line_glob_arg_name ] ~doc ~docv:"PATTERN"
)
let single_line_arg =
let doc =
"Use single line search mode by default."
in
Arg.(
value
& flag
& info [ "single-line" ] ~doc
)
let open_with_arg_name = "open-with"
let open_with_arg =
let doc =
Fmt.str "Specify custom command CMD for
opening files with file extension EXT.
May be specified multiple times.
Leading dots of EXT are removed.
LAUNCH_MODE specifies how the command should be
executed:
`terminal` - for commands which Docfd
should run in the terminal and wait for completion,
e.g. text editors, pagers.
`detached` - for background
commands, such as PDF viewers or
other GUI tools.
CMD may contain the following placeholders:
{path} - file path,
{page_num} - page number (PDF only),
{line_num} - line number (not available in PDF),
{search_word} - most unique word of the page
(PDF only, useful for passing to PDF viewers as search term).
Examples: \"pdf:detached='okular --page {page_num} --find {search_word} {path}'\",
\"txt:terminal='nano +{line_num} {path}'\".
"
in
Arg.(
value
& opt_all string []
& info [ open_with_arg_name ] ~doc ~docv:"EXT:LAUNCH_MODE=CMD"
)
let files_with_match_arg_name = "files-with-match"
let files_with_match_arg =
let doc =
Fmt.str "If paired with
--%s or --%s,
then print the paths of documents with at least one match
instead of printing the search results.
If paired with --%s, then print paths of documents
that would have be listed in the UI
after running the commands in interactive mode."
search_arg_name
sample_arg_name
script_arg_name
in
Arg.(
value
& flag
& info [ "l"; files_with_match_arg_name ] ~doc
)
let files_without_match_arg_name = "files-without-match"
let files_without_match_arg =
let doc =
Fmt.str "If paired with
--%s or --%s,
then print the paths of documents with no matches
instead of printing the search results.
Cannot be paired with --%s."
search_arg_name
sample_arg_name
script_arg_name
in
Arg.(
value
& flag
& info [ files_without_match_arg_name ] ~doc
)
let sort_no_score_arg_name = "sort-no-score"
let sort_no_score_arg =
let doc =
Fmt.str
"Same as --%s but sorting TYPE cannot be score. Used for scenarios when no scores are available, e.g. --%s is used."
sort_no_score_arg_name
files_without_match_arg_name
in
Arg.(
value
& opt string Params.default_sort_by_no_score_arg
& info [ sort_no_score_arg_name ] ~doc ~docv:"TYPE,ORDER"
)
let paths_arg =
let doc =
Fmt.str
"PATH can be either file or directory.
Directories are scanned for files with matching extensions.
If no paths are provided,
then Docfd defaults to scanning the current working directory
unless any of the following is used: %a.
To use piped stdin as input, the list of paths must be empty."
Fmt.(list ~sep:comma (fun fmt s -> Fmt.pf fmt "--%s" s))
[ paths_from_arg_name; glob_arg_name; single_line_glob_arg_name ]
in
Arg.(value & pos_all string [] & info [] ~doc ~docv:"PATH")
let check
~max_depth
~max_fuzzy_edit_dist
~max_token_search_dist
~max_linked_token_search_dist
~tokens_per_search_scope_level
~index_chunk_size
~cache_limit
~start_with_filter
~start_with_search
~filter_exp
~sample_search_exp
~samples_per_doc
~search_exp
~search_result_print_text_width
~search_result_print_snippet_min_size
~search_result_print_max_add_lines
~start_with_script
~script
~paths_from
~print_files_with_match
~print_files_without_match
=
if max_depth < 0 then (
exit_with_error_msg
(Fmt.str "invalid %s: cannot be < 0" max_depth_arg_name)
);
if max_fuzzy_edit_dist < 0 then (
exit_with_error_msg
(Fmt.str "invalid %s: cannot be < 0" max_fuzzy_edit_dist_arg_name)
);
if max_token_search_dist < 1 then (
exit_with_error_msg
(Fmt.str "invalid %s: cannot be < 1" max_token_search_dist_arg_name)
);
if max_linked_token_search_dist < 1 then (
exit_with_error_msg
(Fmt.str "invalid %s: cannot be < 1" max_linked_token_search_dist_arg_name)
);
if tokens_per_search_scope_level < 1 then (
exit_with_error_msg
(Fmt.str "invalid %s: cannot be < 1" tokens_per_search_scope_level_arg_name)
);
if index_chunk_size < 1 then (
exit_with_error_msg
(Fmt.str "invalid %s: cannot be < 1" index_chunk_size_arg_name)
);
if cache_limit < 1 then (
exit_with_error_msg
(Fmt.str "invalid %s: cannot be < 1" cache_limit_arg_name)
);
if samples_per_doc < 1 then (
exit_with_error_msg
(Fmt.str "invalid %s: cannot be < 1" samples_per_doc_arg_name)
);
if search_result_print_text_width < 1 then (
exit_with_error_msg
(Fmt.str "invalid %s: cannot be < 1" search_result_print_text_width_arg_name)
);
if search_result_print_snippet_min_size < 0 then (
exit_with_error_msg
(Fmt.str "invalid %s: cannot be < 0" search_result_print_snippet_min_size_arg_name)
);
if search_result_print_max_add_lines < 0 then (
exit_with_error_msg
(Fmt.str "invalid %s: cannot be < 0" search_result_print_snippet_max_add_lines_arg_name)
);
if Option.is_some filter_exp then (
if not (
Option.is_some sample_search_exp
||
Option.is_some search_exp
||
print_files_with_match
||
print_files_without_match
)
then (
exit_with_error_msg
(Fmt.str "--%s must be used with at least one of: --%s, --%s, --%s, --%s"
filter_arg_name
search_arg_name
sample_arg_name
files_with_match_arg_name
files_without_match_arg_name
)
)
);
let cannot_be_used_together x y =
exit_with_error_msg
(Fmt.str "--%s and --%s cannot be used together" x y)
in
(match print_files_with_match, print_files_without_match with
| true, true -> (
cannot_be_used_together
files_with_match_arg_name files_without_match_arg_name
)
| true, false -> (
if not (
Option.is_some filter_exp
||
Option.is_some sample_search_exp
||
Option.is_some search_exp
||
Option.is_some script
)
then (
exit_with_error_msg
(Fmt.str "--%s cannot be used without one of: --%s, --%s, --%s, --%s"
files_with_match_arg_name
filter_arg_name
sample_arg_name
search_arg_name
script_arg_name
)
)
)
| false, true -> (
if not (
Option.is_some filter_exp
||
Option.is_some sample_search_exp
||
Option.is_some search_exp
)
then (
exit_with_error_msg
(Fmt.str "--%s cannot be used without one of: --%s, --%s, --%s"
files_without_match_arg_name
filter_arg_name
sample_arg_name
search_arg_name
)
)
)
| false, false -> ()
);
(
let l = List.filter (fun x -> x = "-") paths_from in
if List.length l > 1 then (
exit_with_error_msg
(Fmt.str "at most one \"-\" may be supplied to --%s" paths_from_arg_name)
)
);
(match filter_exp with
| None -> ()
| Some filter_exp_string -> (
match
Filter_exp.parse filter_exp_string
with
| None -> (
exit_with_error_msg "failed to parse filter exp"
)
| Some _ -> ()
)
);
(match sample_search_exp, search_exp with
| None, None -> ()
| Some _, Some _ -> (
exit_with_error_msg
(Fmt.str "%s and %s cannot be used together" sample_arg_name search_arg_name)
)
| Some search_exp_string, None
| None, Some search_exp_string -> (
match
Search_exp.parse search_exp_string
with
| None -> (
exit_with_error_msg "failed to parse search exp"
)
| Some _ -> ()
)
);
let start_with_arg_check ~arg_name =
if Option.is_some filter_exp then (
cannot_be_used_together arg_name filter_arg_name
);
if Option.is_some sample_search_exp then (
cannot_be_used_together arg_name sample_arg_name
);
if Option.is_some search_exp then (
cannot_be_used_together arg_name search_arg_name
);
in
if Option.is_some start_with_filter then (
start_with_arg_check ~arg_name:start_with_filter_arg_name
);
if Option.is_some start_with_search then (
start_with_arg_check ~arg_name:start_with_search_arg_name
);
if Option.is_some start_with_script then (
start_with_arg_check ~arg_name:start_with_script_arg_name
);
let script_common_check ~arg_name =
if Option.is_some filter_exp then (
cannot_be_used_together arg_name filter_arg_name
);
if Option.is_some sample_search_exp then (
cannot_be_used_together arg_name sample_arg_name
);
if Option.is_some search_exp then (
cannot_be_used_together arg_name search_arg_name
);
if Option.is_some start_with_filter then (
cannot_be_used_together arg_name start_with_filter_arg_name
);
if Option.is_some start_with_search then (
cannot_be_used_together arg_name start_with_search_arg_name
);
if print_files_without_match then (
cannot_be_used_together arg_name files_without_match_arg_name
);
in
if Option.is_some script then (
script_common_check ~arg_name:script_arg_name;
if Option.is_some start_with_script then (
cannot_be_used_together script_arg_name start_with_script_arg_name
);
);
if Option.is_some start_with_script then (
script_common_check ~arg_name:start_with_script_arg_name;
)
================================================
FILE: bin/clipboard.ml
================================================
let pipe_to_clipboard (f : out_channel -> unit) : unit =
match Params.clipboard_copy_cmd_and_args with
| None -> ()
| Some (cmd, args) -> (
Proc_utils.pipe_to_command f
cmd args
)
================================================
FILE: bin/command.ml
================================================
open Docfd_lib
module Sort_by = struct
type typ = [
| `Path_date
| `Path
| `Score
| `Mod_time
]
type t = typ * Document.Compare.order
let default : t = (`Score, `Desc)
let default_no_score : t = (`Path, `Asc)
let pp formatter ((typ, order) : t) =
Fmt.pf formatter "%s,%s"
(match typ with
| `Path_date -> "path-date"
| `Path -> "path"
| `Score -> "score"
| `Mod_time -> "mod-time"
)
(match order with
| `Asc -> "asc"
| `Desc -> "desc"
)
let p ~no_score : t Angstrom.t =
let open Angstrom in
let open Parser_components in
skip_spaces *>
(choice (List.filter_map
Fun.id ([
Some (string "path-date" *> return `Path_date);
Some (string "path" *> return `Path);
(if no_score then
None
else
Some (string "score" *> return `Score));
Some (string "mod-time" *> return `Mod_time);
]))
<|>
(take_while (fun c -> is_not_space c && c <> ',') >>=
fun s -> fail (Fmt.str "unrecognized sort by type: %s" s))
)
>>= fun typ ->
skip_spaces *>
char ',' *> skip_spaces *>
(choice [
string "asc" *> return `Asc;
string "desc" *> return `Desc;
]
<|>
(take_while is_not_space >>=
fun s -> fail (Fmt.str "unrecognized sort by order: %s" s))
)
>>= fun order -> (
return (typ, order)
)
let parse ~no_score s =
match Angstrom.(parse_string ~consume:Consume.All) (p ~no_score) s with
| Ok t -> Ok t
| Error msg -> Error msg
end
type screen_split = [
| `Even
| `Focus_left
| `Wide_left
| `Focus_right
| `Wide_right
]
let screen_split_of_int (x : int) : screen_split =
if x <= 0 then
`Focus_right
else if x = 1 then
`Wide_right
else if x = 2 then
`Even
else if x = 3 then
`Wide_left
else
`Focus_left
let int_of_screen_split (x : screen_split) =
match x with
| `Focus_right -> 0
| `Wide_right -> 1
| `Even -> 2
| `Wide_left -> 3
| `Focus_left -> 4
type pane = [
| `Bottom_right
| `Key_binding_info
]
let string_of_pane (x : pane) =
match x with
| `Bottom_right -> "bottom-right"
| `Key_binding_info -> "key-binding-info"
type t = [
| `Mark of string
| `Mark_listed
| `Unmark of string
| `Unmark_listed
| `Unmark_all
| `Drop of string
| `Drop_all_except of string
| `Drop_marked
| `Drop_unmarked
| `Drop_listed
| `Drop_unlisted
| `Narrow_level of int
| `Sort of Sort_by.t * Sort_by.t
| `Path_fuzzy_rank of string * int String_map.t option
| `Split_screen of screen_split
| `Hide_pane of pane
| `Show_pane of pane
| `Comment of string
| `Focus of string
| `Search of string
| `Filter of string
]
let pp fmt (t : t) =
match t with
| `Mark s -> Fmt.pf fmt "mark: %s" s
| `Mark_listed -> Fmt.pf fmt "mark listed"
| `Unmark s -> Fmt.pf fmt "unmark: %s" s
| `Unmark_listed -> Fmt.pf fmt "unmark listed"
| `Unmark_all -> Fmt.pf fmt "unmark all"
| `Drop s -> Fmt.pf fmt "drop: %s" s
| `Drop_all_except s -> Fmt.pf fmt "drop all except: %s" s
| `Drop_marked -> Fmt.pf fmt "drop marked"
| `Drop_unmarked -> Fmt.pf fmt "drop unmarked"
| `Drop_listed -> Fmt.pf fmt "drop listed"
| `Drop_unlisted -> Fmt.pf fmt "drop unlisted"
| `Narrow_level x -> Fmt.pf fmt "narrow level: %d" x
| `Sort (x, y) -> (
Fmt.pf fmt "sort by: %a; %a"
Sort_by.pp
x
Sort_by.pp
y
)
| `Path_fuzzy_rank (s, _ranking) -> (
Fmt.pf fmt "path fuzzy rank: %s" s
)
| `Split_screen s -> (
Fmt.pf fmt "split screen: %s"
(match s with
| `Even -> "even"
| `Focus_left -> "focus-left"
| `Wide_left -> "wide-left"
| `Focus_right -> "focus-right"
| `Wide_right -> "wide-right"
)
)
| `Hide_pane pane -> (
Fmt.pf fmt "hide-pane: %s" (string_of_pane pane)
)
| `Show_pane pane -> (
Fmt.pf fmt "show-pane: %s" (string_of_pane pane)
)
| `Comment s -> Fmt.pf fmt "#%s" s
| `Focus s -> Fmt.pf fmt "focus: %s" s
| `Search s -> (
if String.length s = 0 then (
Fmt.pf fmt "clear search"
) else (
Fmt.pf fmt "search: %s" s
)
)
| `Filter s -> (
if String.length s = 0 then (
Fmt.pf fmt "clear filter"
) else (
Fmt.pf fmt "filter: %s" s
)
)
let to_string (t : t) =
Fmt.str "%a" pp t
module Parsers = struct
type t' = t
open Angstrom
open Parser_components
let any_string_trimmed =
any_string >>| String.trim
let p : t' Angstrom.t =
skip_spaces *>
choice [
string "mark" *> skip_spaces *> (
choice [
char ':' *> skip_spaces *>
any_string_trimmed >>| (fun s -> (`Mark s));
string "listed" *> skip_spaces *> return `Mark_listed;
]
);
string "unmark" *> skip_spaces *> (
choice [
string "listed" *> skip_spaces *> return `Unmark_listed;
string "all" *> skip_spaces *> return `Unmark_all;
char ':' *> skip_spaces *>
any_string_trimmed >>| (fun s -> (`Unmark s));
]
);
string "drop" *> skip_spaces *> (
choice [
char ':' *> skip_spaces *>
any_string_trimmed >>| (fun s -> (`Drop s));
string "all" *> skip_spaces *>
string "except" *> skip_spaces *> char ':' *> skip_spaces *>
any_string_trimmed >>| (fun s -> (`Drop_all_except s));
string "listed" *> skip_spaces *> return `Drop_listed;
string "unlisted" *> skip_spaces *> return `Drop_unlisted;
string "marked" *> skip_spaces *> return `Drop_marked;
string "unmarked" *> skip_spaces *> return `Drop_unmarked;
]
);
string "narrow" *> skip_spaces *> (
choice [
string "level" *> skip_spaces *>
char ':' *> skip_spaces *>
satisfy (function '0'..'9' -> true | _ -> false) <* skip_spaces >>|
(fun c -> `Narrow_level (Char.code c - Char.code '0'));
]
);
string "clear" *> skip_spaces *> (
choice [
string "search" *> skip_spaces *> return (`Search "");
string "filter" *> skip_spaces *> return (`Filter "");
]
);
string "path" *> skip_spaces *>
string "fuzzy" *> skip_spaces *>
string "rank" *> skip_spaces *>
char ':' *> skip_spaces *>
any_string_trimmed >>|
(fun s -> `Path_fuzzy_rank (s, None));
(string "sort" *> skip_spaces *>
string "by" *> skip_spaces *>
char ':' *> skip_spaces *>
Sort_by.p ~no_score:false >>= fun sort_by ->
skip_spaces *>
char ';' *>
skip_spaces *>
Sort_by.p ~no_score:true >>= fun sort_by_no_score ->
skip_spaces *>
return (`Sort (sort_by, sort_by_no_score)));
string "focus" *> skip_spaces *>
char ':' *> skip_spaces *>
any_string_trimmed >>| (fun s -> (`Focus s));
string "split" *> skip_spaces *>
string "screen" *> skip_spaces *>
char ':' *> skip_spaces *> (
choice [
string "even" *> skip_spaces *> return (`Split_screen `Even);
string "focus-left" *> skip_spaces *> return (`Split_screen `Focus_left);
string "wide-left" *> skip_spaces *> return (`Split_screen `Wide_left);
string "focus-right" *> skip_spaces *> return (`Split_screen `Focus_right);
string "wide-right" *> skip_spaces *> return (`Split_screen `Wide_right);
]
);
string "hide-pane" *> skip_spaces *>
char ':' *> skip_spaces *> (
choice [
string "bottom-right" *> skip_spaces *> return (`Hide_pane `Bottom_right);
string "key-binding-info" *> skip_spaces *> return (`Hide_pane `Key_binding_info);
]
);
string "show-pane" *> skip_spaces *>
char ':' *> skip_spaces *> (
choice [
string "bottom-right" *> skip_spaces *> return (`Show_pane `Bottom_right);
string "key-binding-info" *> skip_spaces *> return (`Show_pane `Key_binding_info);
]
);
string "#" *> any_string >>| (fun s -> (`Comment s));
string "search" *> skip_spaces *>
char ':' *> skip_spaces *>
any_string_trimmed >>| (fun s -> (`Search s));
string "filter" *> skip_spaces *>
char ':' *> skip_spaces *>
any_string_trimmed >>| (fun s -> (`Filter s));
]
end
let of_string (s : string) : t option =
match Angstrom.(parse_string ~consume:Consume.All) Parsers.p s with
| Ok t -> Some t
| Error _ -> None
================================================
FILE: bin/content_and_search_result_rendering.ml
================================================
open Docfd_lib
module I = Notty.I
module A = Notty.A
type cell_typ = [
| `Plain
| `Search_result
]
type cell = {
word : string;
typ : cell_typ;
}
module Text_block_rendering = struct
let hchunk_rev ~width (img : Notty.image) : Notty.image list =
let open Notty in
let rec aux acc img =
let img_width = I.width img in
if img_width <= width then (
img :: acc
) else (
let acc = (I.hcrop 0 (img_width - width) img) :: acc in
aux acc (I.hcrop width 0 img)
)
in
aux [] img
let of_cells ?attr ~width ?(underline = false) (cells : cell list) : Notty.image * Int_set.t =
let open Notty.Infix in
assert (width > 0);
let rendered_lines_with_search_result_words = ref Int_set.empty in
let grid : Notty.image list list =
List.fold_left
(fun ((cur_len, acc) : int * Notty.image list list) (cell : cell) ->
let attr =
match attr with
| Some attr -> attr
| None -> (match cell.typ with
| `Plain -> A.empty
| `Search_result -> A.(fg black ++ bg lightyellow)
)
in
let word =
(match I.string attr cell.word with
| s -> s
| exception _ -> (
I.string A.(fg lightred) (String.make (String.length cell.word) '?')
))
in
let word_len = I.width word in
let word =
match cell.typ with
| `Plain -> word
| `Search_result -> (
if underline then (
word
<->
(I.string A.empty (String.make word_len '^'))
) else (
word
)
)
in
let new_len = cur_len + word_len in
let cur_len, acc =
if new_len <= width then (
match acc with
| [] -> (new_len, [ [ word ] ])
| line :: rest -> (
(new_len, (word :: line) :: rest)
)
) else (
if word_len <= width then (
(word_len, [ word ] :: acc)
) else (
let lines =
hchunk_rev ~width word
|> List.map (fun x -> [ x ])
in
(0, [] :: (lines @ acc))
)
)
in
(match cell.typ with
| `Plain -> ()
| `Search_result -> (
rendered_lines_with_search_result_words :=
Int_set.add (List.length acc - 1) !rendered_lines_with_search_result_words
));
(cur_len, acc)
)
(0, [])
cells
|> snd
|> List.rev_map List.rev
in
let img =
grid
|> List.map I.hcat
|> I.vcat
in
(img, !rendered_lines_with_search_result_words)
let of_words ?attr ~width ?underline ?(highlights = Int_set.empty) (words : string list) : Notty.image =
of_cells
?attr
~width
?underline
(List.mapi
(fun i word ->
if Int_set.mem i highlights then (
{ word; typ = `Search_result }
) else (
{ word; typ = `Plain }
)
)
words)
|> fst
end
type word_grid = {
start_global_line_num : int;
data : cell array array;
}
let start_and_end_inc_global_line_num_of_search_result
~doc_id
(search_result : Search_result.t)
: (int * int) =
match Search_result.found_phrase search_result with
| [] -> failwith "unexpected case"
| l -> (
List.fold_left (fun s_e Search_result.{ found_word_pos; _ } ->
let loc = Index.loc_of_pos ~doc_id found_word_pos in
let line_loc = Index.Loc.line_loc loc in
let global_line_num = Index.Line_loc.global_line_num line_loc in
match s_e with
| None -> (
Some (global_line_num, global_line_num)
)
| Some (s, e) -> (
Some (min s global_line_num, max global_line_num e)
)
)
None
l
|> Option.get
)
let word_grid_of_index
~doc_id
~start_global_line_num
~end_inc_global_line_num
: word_grid =
let global_line_count = Index.global_line_count ~doc_id in
let check x =
assert (0 <= x);
assert (x <= global_line_count - 1);
in
check start_global_line_num;
check end_inc_global_line_num;
if global_line_count = 0 then (
{ start_global_line_num = 0; data = [||] }
) else (
let data =
OSeq.(start_global_line_num -- end_inc_global_line_num)
|> Seq.map (fun global_line_num ->
let data =
Index.words_of_global_line_num ~doc_id global_line_num
|> Dynarray.to_seq
|> Seq.map (fun word -> { word; typ = `Plain })
|> Array.of_seq
in
data
)
|> Array.of_seq
in
{ start_global_line_num; data }
)
let mark_in_word_grid
~doc_id
(grid : word_grid)
(positions : int list)
: unit =
let grid_end_inc_global_line_num = grid.start_global_line_num + Array.length grid.data - 1 in
List.iter (fun pos ->
let loc = Index.loc_of_pos ~doc_id
gitextract_8iyswmwt/ ├── .gitattributes ├── .github/ │ └── workflows/ │ └── deploy.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── bin/ │ ├── BLAKE2B.ml │ ├── UI.ml │ ├── UI_base.ml │ ├── args.ml │ ├── clipboard.ml │ ├── command.ml │ ├── content_and_search_result_rendering.ml │ ├── debug_utils.ml │ ├── docfd.ml │ ├── document.ml │ ├── document.mli │ ├── document_pipeline.ml │ ├── document_pipeline.mli │ ├── document_src.ml │ ├── dune │ ├── file_utils.ml │ ├── filter_exp.ml │ ├── glob.ml │ ├── glob.mli │ ├── lock_protected_cell.ml │ ├── lock_protected_cell.mli │ ├── misc_utils.ml │ ├── params.ml │ ├── path_opening.ml │ ├── ping.ml │ ├── ping.mli │ ├── printers.ml │ ├── proc_utils.ml │ ├── result_syntax.ml │ ├── script.ml │ ├── search_mode.ml │ ├── session.ml │ ├── session.mli │ ├── session_manager.ml │ ├── session_manager.mli │ ├── string_utils.ml │ ├── version_string.ml │ └── xdg_utils.ml ├── containers/ │ ├── Containerfile.demo-vhs │ └── Containerfile.docfd ├── demo-vhs-tapes/ │ ├── repo-non-interactive.tape │ ├── repo.tape │ └── ui-screenshot.tape ├── demo-vhs.sh ├── docfd.opam ├── docfd.opam.locked ├── docfd.opam.template ├── dune-project ├── file-collection-tests.t/ │ ├── dune │ └── run.t ├── lib/ │ ├── GZIP.ml │ ├── char_map.ml │ ├── doc_id_db.ml │ ├── doc_id_db.mli │ ├── docfd_lib.ml │ ├── dune │ ├── index.ml │ ├── index.mli │ ├── int_map.ml │ ├── int_set.ml │ ├── link.ml │ ├── misc_utils.ml │ ├── option_syntax.ml │ ├── params.ml │ ├── parser_components.ml │ ├── search_exp.ml │ ├── search_exp.mli │ ├── search_phrase.ml │ ├── search_phrase.mli │ ├── search_result.ml │ ├── search_result.mli │ ├── search_result_heap.ml │ ├── sqlite3_utils.ml │ ├── stop_signal.ml │ ├── stop_signal.mli │ ├── string_map.ml │ ├── string_set.ml │ ├── task_pool.ml │ ├── task_pool.mli │ ├── tokenization.ml │ ├── word_db.ml │ └── word_db.mli ├── line-wrapping-tests.t/ │ ├── dune │ ├── long-words.txt │ ├── run.t │ ├── sentences.txt │ └── words.txt ├── match-type-tests.t/ │ ├── dune │ ├── run.t │ └── test.txt ├── misc-behavior-tests.t/ │ ├── abcd.txt │ ├── dune │ └── run.t ├── non-interactive-mode-return-code-tests.t/ │ ├── dune │ └── run.t ├── open-with-tests.t/ │ ├── dune │ └── run.t ├── printing-tests.t/ │ ├── empty.txt │ ├── run.t │ ├── test0.txt │ ├── test1.txt │ ├── test2.txt │ ├── test3.txt │ └── test4.txt ├── profiling/ │ ├── dune │ └── main.ml ├── publish.sh ├── run-container.sh ├── script-tests.t/ │ ├── dune │ └── run.t ├── search-scope-narrowing-tests.t/ │ ├── dune │ └── run.t ├── tests/ │ ├── dune │ ├── main.ml │ ├── search_exp_tests.ml │ ├── test_utils.ml │ └── utils_tests.ml └── update-version-string.py
Condensed preview — 125 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (667K chars).
[
{
"path": ".gitattributes",
"chars": 28,
"preview": "*.t/run.t linguist-vendored\n"
},
{
"path": ".github/workflows/deploy.yml",
"chars": 3129,
"preview": "name: Deploy on release\n\non:\n push:\n tags:\n - \"[0-9]*\"\n - \"test*\"\n branches:\n - \"ci-test\"\n\njobs:\n "
},
{
"path": ".gitignore",
"chars": 237,
"preview": "_build/\n_coverage/\n.merlin\n*.rst~\n*.install\nbisect*.out\nbisect*.coverage\nfuzz-*-input\nfuzz-*-output\nfuzz-logs/\n/test*.md"
},
{
"path": "CHANGELOG.md",
"chars": 48241,
"preview": "# Changelog\n\n## 13.0.0\n\n- Removed fzf dependency entirely\n - Switched from fzf to a built-in implementation for fuzzy"
},
{
"path": "LICENSE",
"chars": 1067,
"preview": "MIT License\n\nCopyright (c) 2022 Di Long Li\n\nPermission is hereby granted, free of charge, to any person obtaining a copy"
},
{
"path": "Makefile",
"chars": 2108,
"preview": "SRCFILES = lib/*.ml lib/*.mli bin/*.ml bin/*.mli profiling/*.ml tests/*.ml\n\nOCPINDENT = ocp-indent \\\n\t--inplace \\\n\t$(SRC"
},
{
"path": "README.md",
"chars": 9655,
"preview": "# Docfd\n\n[Online Demo](https://demo.docfd.sh)\n\nTUI multiline fuzzy document finder\n\nThink interactive grep for text file"
},
{
"path": "bin/BLAKE2B.ml",
"chars": 859,
"preview": "module B = Digestif.Make_BLAKE2B (struct\n let digest_size = 64\n end)\n\nlet hash_of_file ~env ~path =\n let fs = Eio.S"
},
{
"path": "bin/UI.ml",
"chars": 70992,
"preview": "open Docfd_lib\nopen Lwd_infix\n\nmodule Vars = struct\n let script_name_field = Lwd.var UI_base.empty_text_field\n\n let sc"
},
{
"path": "bin/UI_base.ml",
"chars": 25957,
"preview": "open Docfd_lib\nopen Lwd_infix\n\ntype input_mode =\n | Navigate\n | Search\n | Filter\n | Clear\n | Sort of [ `Asc | `Desc"
},
{
"path": "bin/args.ml",
"chars": 21736,
"preview": "open Cmdliner\nopen Docfd_lib\nopen Misc_utils\n\nlet no_pdftotext_arg_name = \"no-pdftotext\"\n\nlet no_pdftotext_arg =\n let d"
},
{
"path": "bin/clipboard.ml",
"chars": 204,
"preview": "let pipe_to_clipboard (f : out_channel -> unit) : unit =\n match Params.clipboard_copy_cmd_and_args with\n | None -> ()\n"
},
{
"path": "bin/command.ml",
"chars": 8735,
"preview": "open Docfd_lib\n\nmodule Sort_by = struct\n type typ = [\n | `Path_date\n | `Path\n | `Score\n | `Mod_time\n ]\n\n "
},
{
"path": "bin/content_and_search_result_rendering.ml",
"chars": 18757,
"preview": "open Docfd_lib\n\nmodule I = Notty.I\n\nmodule A = Notty.A\n\ntype cell_typ = [\n | `Plain\n | `Search_result\n]\n\ntype cell = {"
},
{
"path": "bin/debug_utils.ml",
"chars": 127,
"preview": "let do_if_debug (f : out_channel -> unit) =\n match !Params.debug_output with\n | None -> ()\n | Some oc -> (\n f oc"
},
{
"path": "bin/docfd.ml",
"chars": 46461,
"preview": "open Cmdliner\nopen Lwd_infix\nopen Docfd_lib\nopen Debug_utils\nopen Misc_utils\nopen File_utils\n\nlet compute_paths_from_glo"
},
{
"path": "bin/document.ml",
"chars": 16784,
"preview": "open Result_syntax\nopen Docfd_lib\n\ntype t = {\n search_mode : Search_mode.t;\n path : string;\n path_parts : string list"
},
{
"path": "bin/document.mli",
"chars": 1525,
"preview": "open Docfd_lib\n\ntype t\n\nval equal : t -> t -> bool\n\nmodule Compare : sig\n type order = [\n | `Asc\n | `Desc\n ]\n\n "
},
{
"path": "bin/document_pipeline.ml",
"chars": 4238,
"preview": "open Docfd_lib\nopen Debug_utils\n\ntype t = {\n env : Eio_unix.Stdenv.base;\n pool : Task_pool.t;\n ir0_queue : Document.I"
},
{
"path": "bin/document_pipeline.mli",
"chars": 200,
"preview": "type t\n\nval make : env:Eio_unix.Stdenv.base -> Docfd_lib.Task_pool.t -> t\n\nval feed : t -> Search_mode.t -> doc_hash:str"
},
{
"path": "bin/document_src.ml",
"chars": 648,
"preview": "type file_collection = {\n default_search_mode_files : String_set.t;\n single_line_search_mode_files : String_set.t;\n}\n\n"
},
{
"path": "bin/dune",
"chars": 1262,
"preview": "(rule\n (targets int_map.ml)\n (deps ../lib/int_map.ml)\n (action (copy# %{deps} %{targets}))\n )\n\n(rule\n (targets int_"
},
{
"path": "bin/file_utils.ml",
"chars": 6988,
"preview": "open Misc_utils\nopen Debug_utils\n\nlet extension_of_file (s : string) =\n Filename.extension s\n |> String.lowercase_asci"
},
{
"path": "bin/filter_exp.ml",
"chars": 5906,
"preview": "open Docfd_lib\n\ntype t =\n | Empty\n | Path_date of compare_op * Timedesc.Date.t\n | Path_fuzzy of Search_exp.t\n | Path"
},
{
"path": "bin/glob.ml",
"chars": 1993,
"preview": "type t = {\n case_sensitive : bool;\n string : string;\n re : Re.re;\n}\n\nlet equal x y =\n x.case_sensitive = y.case_sens"
},
{
"path": "bin/glob.mli",
"chars": 209,
"preview": "type t\n\nval parse : ?case_sensitive:bool -> string -> t option\n\nval equal : t -> t -> bool\n\nval is_empty : t -> bool\n\nva"
},
{
"path": "bin/lock_protected_cell.ml",
"chars": 499,
"preview": "type 'a t = {\n lock : Eio.Mutex.t;\n mutable data : 'a option;\n}\n\nlet make () =\n {\n lock = Eio.Mutex.create ();\n "
},
{
"path": "bin/lock_protected_cell.mli",
"chars": 120,
"preview": "type 'a t\n\nval make : unit -> 'a t\n\nval set : 'a t -> 'a -> unit\n\nval unset : 'a t -> unit\n\nval get : 'a t -> 'a option\n"
},
{
"path": "bin/misc_utils.ml",
"chars": 5468,
"preview": "open Docfd_lib\ninclude Docfd_lib.Misc_utils'\n\nlet bound_selection ~choice_count (x : int) : int =\n max 0 (min (choice_c"
},
{
"path": "bin/params.ml",
"chars": 5709,
"preview": "include Docfd_lib.Params'\n\nlet debug_output : out_channel option ref = ref None\n\nlet scan_hidden = ref false\n\nlet defaul"
},
{
"path": "bin/path_opening.ml",
"chars": 14871,
"preview": "open Docfd_lib\nopen Debug_utils\n\ntype launch_mode = [ `Terminal | `Detached ]\n\ntype spec = string list * launch_mode * s"
},
{
"path": "bin/ping.ml",
"chars": 300,
"preview": "type t = {\n queue : unit Eio.Stream.t;\n}\n\nlet make () =\n {\n queue = Eio.Stream.create Int.max_int;\n }\n\nlet ping (t"
},
{
"path": "bin/ping.mli",
"chars": 96,
"preview": "type t\n\nval make : unit -> t\n\nval ping : t -> unit\n\nval wait : t -> unit\n\nval clear : t -> unit\n"
},
{
"path": "bin/printers.ml",
"chars": 1453,
"preview": "let output_image ~color (oc : out_channel) (img : Notty.image) : unit =\n let open Notty in\n let buf = Buffer.create 10"
},
{
"path": "bin/proc_utils.ml",
"chars": 1943,
"preview": "open Misc_utils\n\nlet command_exists (cmd : string) : bool =\n Sys.command (Fmt.str \"command -v %s 2>/dev/null 1>/dev/nul"
},
{
"path": "bin/result_syntax.ml",
"chars": 62,
"preview": "let ( let* ) = Result.bind\n\nlet ( let+ ) x y = Result.map y x\n"
},
{
"path": "bin/script.ml",
"chars": 1693,
"preview": "let run pool ~init_state ~path\n : (Session.Snapshot.t Dynarray.t, string) result =\n let exception Error_with_msg of st"
},
{
"path": "bin/search_mode.ml",
"chars": 45,
"preview": "type t = [\n | `Single_line\n | `Multiline\n]\n"
},
{
"path": "bin/session.ml",
"chars": 26170,
"preview": "open Docfd_lib\n\ntype search_result_group = Document.t * Search_result.t array\n\nmodule State = struct\n module Sort_by = "
},
{
"path": "bin/session.mli",
"chars": 2021,
"preview": "open Docfd_lib\n\ntype search_result_group = Document.t * Search_result.t array\n\nmodule State : sig\n type t\n\n val equal "
},
{
"path": "bin/session_manager.ml",
"chars": 14662,
"preview": "open Docfd_lib\n\nlet last_request_timestamp : Mtime.t Atomic.t =\n Atomic.make (Mtime_clock.now ())\n\nlet search_request :"
},
{
"path": "bin/session_manager.mli",
"chars": 768,
"preview": "open Docfd_lib\n\nval manager_fiber : unit -> unit\n\nval worker_fiber : Task_pool.t -> unit\n\nval cur_snapshot : (int * Sess"
},
{
"path": "bin/string_utils.ml",
"chars": 1090,
"preview": "let remove_leading_dots (s : string) =\n let str_len = String.length s in\n if str_len = 0 then (\n \"\"\n ) else (\n "
},
{
"path": "bin/version_string.ml",
"chars": 17,
"preview": "let s = \"13.0.0\"\n"
},
{
"path": "bin/xdg_utils.ml",
"chars": 2151,
"preview": "let all_desktop_files () : string Seq.t =\n match Sys.getenv_opt \"XDG_DATA_DIRS\" with\n | None -> Seq.empty\n | Some s -"
},
{
"path": "containers/Containerfile.demo-vhs",
"chars": 120,
"preview": "FROM ghcr.io/charmbracelet/vhs\n\nRUN apt-get update\n\nRUN apt-get install -y poppler-utils\n\nRUN apt-get install -y neovim\n"
},
{
"path": "containers/Containerfile.docfd",
"chars": 680,
"preview": "FROM docker.io/alpine:3.22\n\nUSER root\nRUN apk add linux-headers\nRUN apk add poppler-utils\nRUN apk add sqlite sqlite-libs"
},
{
"path": "demo-vhs-tapes/repo-non-interactive.tape",
"chars": 236,
"preview": "Output demo-vhs-gifs/repo-non-interactive.gif\n\nSet Padding 0\nSet Framerate 10\n\nSet Width 1366\nSet Height 768\nSet FontS"
},
{
"path": "demo-vhs-tapes/repo.tape",
"chars": 454,
"preview": "Output demo-vhs-gifs/repo.gif\n\nSet Padding 0\nSet Framerate 10\n\nSet Width 1366\nSet Height 768\nSet FontSize 15\n\nSet Typi"
},
{
"path": "demo-vhs-tapes/ui-screenshot.tape",
"chars": 202,
"preview": "Output dummy.gif\n\nSet Padding 0\nSet Framerate 10\n\nSet Width 1366\nSet Height 768\nSet FontSize 15\n\nSet TypingSpeed 100ms"
},
{
"path": "demo-vhs.sh",
"chars": 153,
"preview": "#!/usr/bin/env bash\n\npodman run --rm -v $PWD:/vhs \\\n --env 'VISUAL=nvim' \\\n -v $PWD/release/docfd:/usr/bin/docfd \\\n l"
},
{
"path": "docfd.opam",
"chars": 1799,
"preview": "# This file is generated by dune, edit dune-project instead\nopam-version: \"2.0\"\nsynopsis: \"TUI multiline fuzzy document "
},
{
"path": "docfd.opam.locked",
"chars": 3668,
"preview": "opam-version: \"2.0\"\nname: \"docfd\"\nversion: \"3.0.0\"\nsynopsis: \"TUI multiline fuzzy document finder\"\nmaintainer: \"Darren L"
},
{
"path": "docfd.opam.template",
"chars": 140,
"preview": "build: [\n [\"dune\" \"subst\"] {dev}\n [\n \"dune\"\n \"build\"\n \"-p\"\n name\n \"-j\"\n jobs\n \"@install\"\n \"@do"
},
{
"path": "dune-project",
"chars": 1531,
"preview": "(lang dune 3.4)\n\n(name docfd)\n\n(generate_opam_files true)\n\n(source\n (github darrenldl/docfd))\n\n(authors \"Darren Li\")\n\n(m"
},
{
"path": "file-collection-tests.t/dune",
"chars": 33,
"preview": "(cram\n (deps ../bin/docfd.exe))\n"
},
{
"path": "file-collection-tests.t/run.t",
"chars": 53780,
"preview": "Setup:\n $ touch no-ext\n $ touch empty-paths.txt\n $ echo \"test.txt\" >> paths\n $ echo \"test-symlink.txt\" >> paths\n $ "
},
{
"path": "lib/GZIP.ml",
"chars": 1447,
"preview": "(* Basically fully copied from examples in Decompress manual *)\n\nlet time () =\n Int32.of_float (Unix.gettimeofday ())\n\n"
},
{
"path": "lib/char_map.ml",
"chars": 26,
"preview": "include CCMap.Make (Char)\n"
},
{
"path": "lib/doc_id_db.ml",
"chars": 2052,
"preview": "type t = {\n lock : Eio.Mutex.t;\n doc_id_of_doc_hash : (string, int64) Hashtbl.t;\n}\n\nlet t : t =\n {\n lock = Eio.Mut"
},
{
"path": "lib/doc_id_db.mli",
"chars": 83,
"preview": "val allocate_bulk : string Seq.t -> unit\n\nval doc_id_of_doc_hash : string -> int64\n"
},
{
"path": "lib/docfd_lib.ml",
"chars": 1072,
"preview": "module Index = Index\n\nmodule Doc_id_db = Doc_id_db\n\nmodule Link = Link\n\nmodule Search_result = Search_result\n\nmodule Sea"
},
{
"path": "lib/dune",
"chars": 564,
"preview": "(library\n (flags (-w \"+a-4-9-29-37-40-42-44-48-50-32-30-70@8\"))\n (name docfd_lib)\n (preprocess (pps\n p"
},
{
"path": "lib/index.ml",
"chars": 50341,
"preview": "module Line_loc = struct\n type t = {\n page_num : int;\n line_num_in_page : int;\n global_line_num : int;\n }\n ["
},
{
"path": "lib/index.mli",
"chars": 2725,
"preview": "module Line_loc : sig\n type t\n\n val page_num : t -> int\n\n val line_num_in_page : t -> int\n\n val global_line_num : t "
},
{
"path": "lib/int_map.ml",
"chars": 25,
"preview": "include CCMap.Make (Int)\n"
},
{
"path": "lib/int_set.ml",
"chars": 25,
"preview": "include CCSet.Make (Int)\n"
},
{
"path": "lib/link.ml",
"chars": 428,
"preview": "type typ = [\n | `Markdown\n | `Wiki\n | `URL\n]\n\nlet string_of_typ (typ : typ) =\n match typ with\n | `Markdown -> \"mark"
},
{
"path": "lib/misc_utils.ml",
"chars": 4476,
"preview": "let exit_with_error_msg (msg : string) =\n Printf.printf \"error: %s\\n\" msg;\n exit 1\n\nlet ci_string_set_of_list (l : str"
},
{
"path": "lib/option_syntax.ml",
"chars": 62,
"preview": "let ( let* ) = Option.bind\n\nlet ( let+ ) x y = Option.map y x\n"
},
{
"path": "lib/params.ml",
"chars": 2696,
"preview": "let default_search_result_total_per_document = 50\n\nlet search_result_min_per_start = 5\n\nlet max_token_size = 500\n\nlet de"
},
{
"path": "lib/parser_components.ml",
"chars": 1154,
"preview": "open Angstrom\n\nlet is_space c =\n match c with\n | ' '\n | '\\t'\n | '\\n'\n | '\\r' -> true\n | _ -> false\n\nlet skip_space"
},
{
"path": "lib/search_exp.ml",
"chars": 5011,
"preview": "type match_typ_marker = [ `Exact | `Prefix | `Suffix ]\n[@@deriving show]\n\ntype exp = [\n | `Word of string\n | `Match_ty"
},
{
"path": "lib/search_exp.mli",
"chars": 191,
"preview": "type t\n\nval pp : Format.formatter -> t -> unit\n\nval empty : t\n\nval is_empty : t -> bool\n\nval flattened : t -> Search_phr"
},
{
"path": "lib/search_phrase.ml",
"chars": 12141,
"preview": "type match_typ = [\n | `Fuzzy\n | `Exact\n | `Suffix\n | `Prefix\n]\n[@@deriving show, ord]\n\ntype match_typ_marker = [ `Ex"
},
{
"path": "lib/search_phrase.mli",
"chars": 1288,
"preview": "type match_typ = [\n | `Fuzzy\n | `Exact\n | `Suffix\n | `Prefix\n]\n[@@deriving show, ord]\n\ntype match_typ_marker = [ `Ex"
},
{
"path": "lib/search_result.ml",
"chars": 13900,
"preview": "type indexed_found_word = {\n found_word_pos : int;\n found_word_ci : string;\n found_word : string;\n}\n\ntype t = {\n sco"
},
{
"path": "lib/search_result.mli",
"chars": 429,
"preview": "type indexed_found_word = {\n found_word_pos : int;\n found_word_ci : string;\n found_word : string;\n}\n\ntype t\n\nval make"
},
{
"path": "lib/search_result_heap.ml",
"chars": 140,
"preview": "include CCHeap.Make (struct\n type t = Search_result.t\n\n let leq x y =\n (Search_result.score x) <= (Search_res"
},
{
"path": "lib/sqlite3_utils.ml",
"chars": 2918,
"preview": "include Sqlite3\n\nlet db_pool =\n Eio.Pool.create\n (* This is not ideal since validate is not called until next use of"
},
{
"path": "lib/stop_signal.ml",
"chars": 472,
"preview": "type t = {\n mutable stop : bool;\n cond : Eio.Condition.t;\n mutex : Eio.Mutex.t;\n}\n\nlet make () =\n {\n stop = false"
},
{
"path": "lib/stop_signal.mli",
"chars": 79,
"preview": "type t\n\nval make : unit -> t\n\nval await : t -> unit\n\nval broadcast : t -> unit\n"
},
{
"path": "lib/string_map.ml",
"chars": 28,
"preview": "include CCMap.Make (String)\n"
},
{
"path": "lib/string_set.ml",
"chars": 28,
"preview": "include CCSet.Make (String)\n"
},
{
"path": "lib/task_pool.ml",
"chars": 861,
"preview": "type t = Eio.Executor_pool.t\n\nlet size = max 1 (Domain.recommended_domain_count () - 1)\n\nlet make ~sw mgr =\n Eio.Execut"
},
{
"path": "lib/task_pool.mli",
"chars": 298,
"preview": "type t\n\nval size : int\n\nval make : sw:Eio.Switch.t -> _ Eio.Domain_manager.t -> t\n\nval run : t -> (unit -> 'a) -> 'a\n\nva"
},
{
"path": "lib/tokenization.ml",
"chars": 2957,
"preview": "let chunk_tokens (s : (int * string) Seq.t) : (int * string) Seq.t =\n let rec aux offset s =\n match s () with\n | "
},
{
"path": "lib/word_db.ml",
"chars": 3941,
"preview": "type t = {\n lock : Eio.Mutex.t;\n mutable size : int;\n mutable size_written_to_db : int;\n mutable word_of_id : string"
},
{
"path": "lib/word_db.mli",
"chars": 280,
"preview": "type t\n\nval add : string -> int\n\nval filter : Task_pool.t -> (string -> bool) -> (int * string) Dynarray.t\n\nval word_of_"
},
{
"path": "line-wrapping-tests.t/dune",
"chars": 33,
"preview": "(cram\n (deps ../bin/docfd.exe))\n"
},
{
"path": "line-wrapping-tests.t/long-words.txt",
"chars": 776,
"preview": "0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789\n\nabcdefghijklmnopqr"
},
{
"path": "line-wrapping-tests.t/run.t",
"chars": 19306,
"preview": "Word breaking:\n $ docfd --cache-dir .cache --search-result-print-snippet-min-size 0 long-words.txt --sample \"01 ab\" --s"
},
{
"path": "line-wrapping-tests.t/sentences.txt",
"chars": 505,
"preview": " Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna "
},
{
"path": "line-wrapping-tests.t/words.txt",
"chars": 43,
"preview": "012345\n\nabcdefg\n\n\n\n\n\n\n\n\n\n\n\n012345\n\nabcdefg\n"
},
{
"path": "match-type-tests.t/dune",
"chars": 33,
"preview": "(cram\n (deps ../bin/docfd.exe))\n"
},
{
"path": "match-type-tests.t/run.t",
"chars": 6642,
"preview": "Exact match:\n $ docfd --cache-dir .cache test.txt --sample \"'abc\"\n [1]\n $ docfd --cache-dir .cache test.txt --sample "
},
{
"path": "match-type-tests.t/test.txt",
"chars": 142,
"preview": "abcd\nabcdef\nABCD\nABCDEF\nABcd\nABcdEF\n\n'abcd\n'abcd'\n^efgh\n^^efgh\nefgh$\nefgh$$\n\nabcd$\nefgh$\n\nhello world\nhello world\n\nHel"
},
{
"path": "misc-behavior-tests.t/abcd.txt",
"chars": 5,
"preview": "abcd\n"
},
{
"path": "misc-behavior-tests.t/dune",
"chars": 33,
"preview": "(cram\n (deps ../bin/docfd.exe))\n"
},
{
"path": "misc-behavior-tests.t/run.t",
"chars": 1392,
"preview": "Stdin temp file cleanup:\n $ echo \"abcd\" | docfd --cache-dir .cache --search \"a\" | tail -n +2\n 1: abcd\n ^^^^\n $ ls"
},
{
"path": "non-interactive-mode-return-code-tests.t/dune",
"chars": 33,
"preview": "(cram\n (deps ../bin/docfd.exe))\n"
},
{
"path": "non-interactive-mode-return-code-tests.t/run.t",
"chars": 1629,
"preview": "Setup:\n $ echo \"0123 abcd\" >> test0.txt\n $ echo \"0123 efgh\" >> test1.txt\n\n--sample, text all files:\n $ docfd --sample"
},
{
"path": "open-with-tests.t/dune",
"chars": 33,
"preview": "(cram\n (deps ../bin/docfd.exe))\n"
},
{
"path": "open-with-tests.t/run.t",
"chars": 5143,
"preview": "Error case tests:\n $ docfd --index-only --open-with pdf:term='okular {path}'\n error: failed to parse pdf:term=okular {"
},
{
"path": "printing-tests.t/empty.txt",
"chars": 0,
"preview": ""
},
{
"path": "printing-tests.t/run.t",
"chars": 1106,
"preview": "--sample:\n $ docfd --cache-dir .cache --sample abcd .\n $TESTCASE_ROOT/test3.txt\n 1: abcd\n ^^^^\n \n $TESTCASE_ROO"
},
{
"path": "printing-tests.t/test0.txt",
"chars": 6,
"preview": "hello\n"
},
{
"path": "printing-tests.t/test1.txt",
"chars": 6,
"preview": "hello\n"
},
{
"path": "printing-tests.t/test2.txt",
"chars": 40,
"preview": "hello\n\nabcd\n\nabcdefgh\n\nhello world abcd\n"
},
{
"path": "printing-tests.t/test3.txt",
"chars": 5,
"preview": "abcd\n"
},
{
"path": "printing-tests.t/test4.txt",
"chars": 5,
"preview": "efgh\n"
},
{
"path": "profiling/dune",
"chars": 559,
"preview": "(rule\n (targets string_set.ml)\n (deps ../lib/string_set.ml)\n (action (copy# %{deps} %{targets}))\n )\n\n(rule\n (target"
},
{
"path": "profiling/main.ml",
"chars": 4775,
"preview": "open Docfd_lib\n\nlet lines = [\n \"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer placerat lacus non cur"
},
{
"path": "publish.sh",
"chars": 1887,
"preview": "#!/usr/bin/env bash\n\nRETAG_CONFIRM_TEXT=\"retag-docfd\"\n\nopam_repo=\"$HOME/opam-repository\"\n\necho \"Checking if $opam_repo e"
},
{
"path": "run-container.sh",
"chars": 162,
"preview": "#!/usr/bin/env bash\n\npodman run -it \\\n -v ~/docfd:/home/docfd \\\n --workdir /home/docfd \\\n --env VISUAL=nano \\\n --rm "
},
{
"path": "script-tests.t/dune",
"chars": 33,
"preview": "(cram\n (deps ../bin/docfd.exe))\n"
},
{
"path": "script-tests.t/run.t",
"chars": 522,
"preview": "Setup:\n $ echo \"abcd\" > test0.txt\n $ echo \"efgh\" > test1.txt\n $ echo \"hijk\" > test2.txt\n $ echo \"0123\" > test3.txt\n "
},
{
"path": "search-scope-narrowing-tests.t/dune",
"chars": 33,
"preview": "(cram\n (deps ../bin/docfd.exe))\n"
},
{
"path": "search-scope-narrowing-tests.t/run.t",
"chars": 8439,
"preview": "Setup:\n $ echo \"abcd\" >> test0.txt\n $ echo \"efgh\" >> test0.txt\n $ echo \"0123\" >> test0.txt\n $ echo \"ijkl\" >> test0.t"
},
{
"path": "tests/dune",
"chars": 230,
"preview": "(executable\n (flags (-w \"+a-4-9-29-37-40-42-44-48-50-70@8\" -g))\n (name main)\n (libraries qcheck-core\n qch"
},
{
"path": "tests/main.ml",
"chars": 649,
"preview": "open Docfd_lib\n\nlet () =\n Eio_main.run (fun env ->\n Eio.Switch.run (fun sw ->\n let _task_pool = Task_pool"
},
{
"path": "tests/search_exp_tests.ml",
"chars": 24036,
"preview": "open Docfd_lib\nopen Test_utils\n\nmodule Alco = struct\n let test_invalid_exp (s : string) =\n Alcotest.(check bool)\n "
},
{
"path": "tests/test_utils.ml",
"chars": 274,
"preview": "open Docfd_lib\n\nlet enriched_token_testable : (module Alcotest.TESTABLE with type t = Search_phrase.Enriched_token.t) =\n"
},
{
"path": "tests/utils_tests.ml",
"chars": 2257,
"preview": "open Docfd_lib\n\nmodule Alco = struct\n let normalize_path_to_absolute_corpus () =\n let test expected input =\n Al"
},
{
"path": "update-version-string.py",
"chars": 529,
"preview": "import os\nimport re\n\nml_path = \"bin/version_string.ml\"\n\nversion = os.environ.get('DOCFD_VERSION_OVERRIDE')\n\nif version i"
}
]
About this extraction
This page contains the full source code of the darrenldl/docfd GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 125 files (618.6 KB), approximately 166.9k tokens. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.