├── .gitignore ├── src ├── lib.rs ├── lore.rs ├── app │ ├── screens.rs │ ├── logging │ │ ├── log_on_error.rs │ │ └── garbage_collector.rs │ ├── screens │ │ ├── bookmarked.rs │ │ ├── mail_list.rs │ │ ├── latest.rs │ │ └── edit_config.rs │ ├── cover_renderer.rs │ ├── config │ │ └── tests.rs │ ├── patch_renderer.rs │ ├── config.rs │ └── logging.rs ├── lore │ ├── mailing_list.rs │ ├── mailing_list │ │ └── tests.rs │ ├── lore_api_client │ │ └── tests.rs │ ├── lore_api_client.rs │ ├── patch │ │ └── tests.rs │ └── patch.rs ├── main.rs ├── ui │ ├── popup.rs │ ├── navigation_bar.rs │ ├── bookmarked.rs │ ├── edit_config.rs │ ├── latest.rs │ ├── mail_list.rs │ ├── popup │ │ ├── info_popup.rs │ │ ├── review_trailers.rs │ │ └── help.rs │ ├── loading_screen.rs │ └── details_actions.rs ├── cli.rs ├── handler │ ├── bookmarked.rs │ ├── edit_config.rs │ ├── latest.rs │ ├── mail_list.rs │ └── details_actions.rs ├── ui.rs ├── handler.rs └── utils.rs ├── test_samples ├── lore_session │ ├── split_patchset │ │ ├── not_a_file │ │ │ └── .gitkeep │ │ ├── expected_cover_letter.cover │ │ ├── patchset_sample_complete.cover │ │ ├── expected_patch_1.mbx │ │ ├── expected_patch_2.mbx │ │ ├── expected_patch_3.mbx │ │ ├── patchset_sample_complete.mbx │ │ └── patchset_sample_without_cover_letter.mbx │ ├── prepare_reply_w_reviewed_by │ │ ├── expected_patch_0-reply.mbx │ │ ├── cover_letter.cover │ │ ├── expected_patch_1-reply.mbx │ │ ├── expected_patch_2-reply.mbx │ │ ├── expected_patch_3-reply.mbx │ │ ├── patch_1.mbx │ │ ├── patch_2.mbx │ │ └── patch_3.mbx │ ├── generate_patch_reply_template │ │ ├── expected_reply_template.mbx │ │ └── patch_sample.mbx │ ├── process_available_lists │ │ └── available_lists_response-3.html │ ├── process_representative_patch │ │ ├── patch_feed_sample_1.xml │ │ └── patch_feed_sample_2.xml │ └── extract_git_reply_command │ │ └── patch_lore_sample.html ├── ui │ └── render_patchset │ │ ├── expected_delta.diff │ │ ├── expected_bat.diff │ │ └── expected_diff-so-fancy.diff └── app │ └── config │ └── config.json ├── .cargo └── config.toml ├── assets └── patch-hub-demo-v0.1.0.gif ├── .github ├── ISSUE_TEMPLATE │ ├── feature-request.md │ ├── code-quality-improvement.md │ └── bug-report.md ├── dependabot.yml └── workflows │ ├── install.yml │ ├── build_and_unit_test.yml │ └── format_and_lint.yml ├── proc_macros ├── Cargo.toml ├── tests │ └── serde_individual_default.rs └── src │ └── lib.rs ├── .pre-commit-config.yaml ├── .idx └── dev.nix ├── Cargo.toml ├── README.md └── CONTRIBUTING.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod lore; 2 | -------------------------------------------------------------------------------- /test_samples/lore_session/split_patchset/not_a_file/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | # Alias for `cargo clippy` 2 | [alias] 3 | lint = "clippy --all-targets --all-features" 4 | -------------------------------------------------------------------------------- /src/lore.rs: -------------------------------------------------------------------------------- 1 | pub mod lore_api_client; 2 | pub mod lore_session; 3 | pub mod mailing_list; 4 | pub mod patch; 5 | -------------------------------------------------------------------------------- /assets/patch-hub-demo-v0.1.0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kworkflow/patch-hub/HEAD/assets/patch-hub-demo-v0.1.0.gif -------------------------------------------------------------------------------- /src/app/screens.rs: -------------------------------------------------------------------------------- 1 | pub mod bookmarked; 2 | pub mod details_actions; 3 | pub mod edit_config; 4 | pub mod latest; 5 | pub mod mail_list; 6 | 7 | #[derive(Debug, Clone, PartialEq)] 8 | pub enum CurrentScreen { 9 | MailingListSelection, 10 | BookmarkedPatchsets, 11 | LatestPatchsets, 12 | PatchsetDetails, 13 | EditConfig, 14 | } 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Request for new functionality 4 | title: '' 5 | labels: new-feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### **Description**: 11 | 12 | [Describe the new functionality] 13 | 14 | ### **Motivation**: 15 | 16 | [Give arguments/motivations for the new functionality] 17 | -------------------------------------------------------------------------------- /test_samples/ui/render_patchset/expected_delta.diff: -------------------------------------------------------------------------------- 1 | 2 | file.txt 3 | ──────────────────────────────────────────────────────────────────────────────── 4 | 5 | ───┐ 6 | 1: │ 7 | ───┘ 8 | Hello, world! 9 | Hello, Rust! 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | target-branch: "unstable" 9 | groups: 10 | version-updates: 11 | applies-to: version-updates 12 | patterns: 13 | - "*" 14 | security-updates: 15 | applies-to: security-updates 16 | patterns: 17 | - "*" 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/code-quality-improvement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Code Quality Improvement 3 | about: Suggestions on how to improve overall code quality 4 | title: '' 5 | labels: code-improv 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### **Context**: 11 | 12 | [Describe the context of the change you are proposing] 13 | 14 | ### **Proposal**: 15 | 16 | [Your proposal of code change] 17 | 18 | ### **Setup**: 19 | 20 | - Project branch: [branch name] 21 | - Project commit hash: [commit hash] 22 | -------------------------------------------------------------------------------- /proc_macros/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "proc_macros" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | derive-getters = { version = "0.5.0", features = ["auto_copy_getters"] } 8 | lazy_static = "1.5.0" 9 | serde = { version = "1.0.203", features = ["derive"] } 10 | serde_json = "1.0.120" 11 | syn = { version = "2.0.100", features = ["full"] } 12 | quote = "1.0.4" 13 | proc-macro2 = "1.0.94" 14 | 15 | [lib] 16 | proc-macro = true 17 | doctest = false # otherwise tests will fail 18 | -------------------------------------------------------------------------------- /test_samples/ui/render_patchset/expected_bat.diff: -------------------------------------------------------------------------------- 1 | diff --git a/file.txt b/file.txt 2 | index 83db48f..e3b0c44 100644 3 | --- a/file.txt 4 | +++ b/file.txt 5 | @@ -1 +1 @@ 6 | -Hello, world! 7 | +Hello, Rust! 8 | -------------------------------------------------------------------------------- /test_samples/lore_session/prepare_reply_w_reviewed_by/expected_patch_0-reply.mbx: -------------------------------------------------------------------------------- 1 | Subject: Re: [PATCH 0/3] file: Implement something 2 | MIME-Version: 1.0 3 | Content-Type: text/plain; charset="utf-8" 4 | Content-Transfer-Encoding: 7bit 5 | 6 | > Patchset description 7 | > 8 | > Foo Bar (3): 9 | > file: Do foo 10 | > file: Do bar 11 | > file: Do foo bar 12 | > 13 | > file.rs | 6 +++--- 14 | > 1 file changed, 3 insertions(+), 3 deletions(-) 15 | > 16 | > -- 17 | > 2.34.1 18 | 19 | Reviewed-by: Bar Foo 20 | -------------------------------------------------------------------------------- /test_samples/lore_session/split_patchset/expected_cover_letter.cover: -------------------------------------------------------------------------------- 1 | Subject: [PATCH 0/3] file: Implement something 2 | From: Sunil Khatri 3 | Date: Tue, 16 Jul 2024 16:49:00 +0000 4 | Message-Id: <1234.567-0-foo@bar.foo.bar> 5 | MIME-Version: 1.0 6 | Content-Type: text/plain; charset="utf-8" 7 | Content-Transfer-Encoding: 7bit 8 | 9 | Patchset description 10 | 11 | Foo Bar (3): 12 | file: Do foo 13 | file: Do bar 14 | file: Do foo bar 15 | 16 | file.rs | 6 +++--- 17 | 1 file changed, 3 insertions(+), 3 deletions(-) 18 | 19 | -- 20 | 2.34.1 21 | -------------------------------------------------------------------------------- /test_samples/lore_session/prepare_reply_w_reviewed_by/cover_letter.cover: -------------------------------------------------------------------------------- 1 | Subject: [PATCH 0/3] file: Implement something 2 | From: Sunil Khatri 3 | Date: Tue, 16 Jul 2024 16:49:00 +0000 4 | Message-Id: <1234.567-0-foo@bar.foo.bar> 5 | MIME-Version: 1.0 6 | Content-Type: text/plain; charset="utf-8" 7 | Content-Transfer-Encoding: 7bit 8 | 9 | Patchset description 10 | 11 | Foo Bar (3): 12 | file: Do foo 13 | file: Do bar 14 | file: Do foo bar 15 | 16 | file.rs | 6 +++--- 17 | 1 file changed, 3 insertions(+), 3 deletions(-) 18 | 19 | -- 20 | 2.34.1 21 | -------------------------------------------------------------------------------- /test_samples/ui/render_patchset/expected_diff-so-fancy.diff: -------------------------------------------------------------------------------- 1 | ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 2 | modified: file.txt 3 | ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── 4 | @ file.txt:1 @ 5 | Hello, world! 6 | Hello, Rust! 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Problems or behaviors that are incorrect 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ### **Description**: 11 | 12 | [Describe the bug] 13 | 14 | ### **How to reproduce**: 15 | 16 | [List the steps to reproduce the bug] 17 | 18 | ### **Expected behavior**: 19 | 20 | [Explain the expected outcome of the steps described] 21 | 22 | ### **Screenshots** 23 | 24 | [If applicable, add screenshots to help explain your problem] 25 | 26 | ### **Setup**: 27 | 28 | - Project branch: [branch name] 29 | - Project commit hash: [commit hash] 30 | -------------------------------------------------------------------------------- /test_samples/lore_session/generate_patch_reply_template/expected_reply_template.mbx: -------------------------------------------------------------------------------- 1 | Subject: Re: [PATCH 1/3] file: Do foo 2 | MIME-Version: 1.0 3 | Content-Type: text/plain; charset="utf-8" 4 | Content-Transfer-Encoding: 7bit 5 | 6 | > Patch 1 description 7 | > 8 | > Signed-off-by: Foo Bar 9 | > --- 10 | > file.rs | 2 +- 11 | > 1 file changed, 1 insertions(+), 1 deletions(-) 12 | > 13 | > diff --git a/file.rs b/file.rs 14 | > index abcdef..fedcba 100644 15 | > --- a/file.rs 16 | > +++ b/file.rs 17 | > @@ -57,6 +57,6 @@ CONTEXT; 18 | > context 19 | > 20 | > -deletion 21 | > +addition 22 | > 23 | > context 24 | > -- 25 | > 2.34.1 26 | -------------------------------------------------------------------------------- /test_samples/lore_session/split_patchset/patchset_sample_complete.cover: -------------------------------------------------------------------------------- 1 | GARB: This is an arbitrary garbage 2 | 3 | Subject: [PATCH 0/3] file: Implement something 4 | From: Sunil Khatri 5 | Date: Tue, 16 Jul 2024 16:49:00 +0000 6 | Message-Id: <1234.567-0-foo@bar.foo.bar> 7 | MIME-Version: 1.0 8 | Content-Type: text/plain; charset="utf-8" 9 | Content-Transfer-Encoding: 7bit 10 | 11 | Patchset description 12 | 13 | Foo Bar (3): 14 | file: Do foo 15 | file: Do bar 16 | file: Do foo bar 17 | 18 | file.rs | 6 +++--- 19 | 1 file changed, 3 insertions(+), 3 deletions(-) 20 | 21 | -- 22 | 2.34.1 23 | 24 | 25 | GARB: to check if the extraction is good 26 | -------------------------------------------------------------------------------- /test_samples/lore_session/prepare_reply_w_reviewed_by/expected_patch_1-reply.mbx: -------------------------------------------------------------------------------- 1 | Subject: Re: [PATCH 1/3] file: Do foo 2 | MIME-Version: 1.0 3 | Content-Type: text/plain; charset="utf-8" 4 | Content-Transfer-Encoding: 7bit 5 | 6 | > Patch 1 description 7 | > 8 | > Signed-off-by: Foo Bar 9 | > --- 10 | > file.rs | 2 +- 11 | > 1 file changed, 1 insertions(+), 1 deletions(-) 12 | > 13 | > diff --git a/file.rs b/file.rs 14 | > index abcdef..fedcba 100644 15 | > --- a/file.rs 16 | > +++ b/file.rs 17 | > @@ -57,6 +57,6 @@ CONTEXT; 18 | > context 19 | > 20 | > -deletion 21 | > +addition 22 | > 23 | > context 24 | > -- 25 | > 2.34.1 26 | 27 | Reviewed-by: Bar Foo 28 | -------------------------------------------------------------------------------- /test_samples/lore_session/prepare_reply_w_reviewed_by/expected_patch_2-reply.mbx: -------------------------------------------------------------------------------- 1 | Subject: Re: [PATCH 2/3] file: Do bar 2 | MIME-Version: 1.0 3 | Content-Type: text/plain; charset="utf-8" 4 | Content-Transfer-Encoding: 7bit 5 | 6 | > Patch 2 description 7 | > 8 | > Signed-off-by: Foo Bar 9 | > --- 10 | > file.rs | 2 +- 11 | > 1 file changed, 1 insertions(+), 1 deletions(-) 12 | > 13 | > diff --git a/file.rs b/file.rs 14 | > index fedcba..123456 100644 15 | > --- a/file.rs 16 | > +++ b/file.rs 17 | > @@ -57,6 +57,6 @@ CONTEXT; 18 | > context 19 | > 20 | > -addition 21 | > +deletion 22 | > 23 | > context 24 | > -- 25 | > 2.34.1 26 | 27 | Reviewed-by: Bar Foo 28 | -------------------------------------------------------------------------------- /test_samples/lore_session/prepare_reply_w_reviewed_by/expected_patch_3-reply.mbx: -------------------------------------------------------------------------------- 1 | Subject: Re: [PATCH 3/3] file: Do foo bar 2 | MIME-Version: 1.0 3 | Content-Type: text/plain; charset="utf-8" 4 | Content-Transfer-Encoding: 7bit 5 | 6 | > Patch 3 description 7 | > 8 | > Signed-off-by: Foo Bar 9 | > --- 10 | > file.rs | 2 +- 11 | > 1 file changed, 1 insertions(+), 1 deletions(-) 12 | > 13 | > diff --git a/file.rs b/file.rs 14 | > index 123456..654321 100644 15 | > --- a/file.rs 16 | > +++ b/file.rs 17 | > @@ -57,6 +57,6 @@ CONTEXT; 18 | > context 19 | > 20 | > -redeletion 21 | > +readdition 22 | > 23 | > context 24 | > -- 25 | > 2.34.1 26 | 27 | Reviewed-by: Bar Foo 28 | -------------------------------------------------------------------------------- /test_samples/lore_session/split_patchset/expected_patch_1.mbx: -------------------------------------------------------------------------------- 1 | Subject: [PATCH 1/3] file: Do foo 2 | From: Foo Bar 3 | Date: Tue, 16 Jul 2024 16:51:00 +0000 4 | Message-Id: <1234.567-1-foo@bar.foo.bar> 5 | MIME-Version: 1.0 6 | Content-Type: text/plain; charset="utf-8" 7 | Content-Transfer-Encoding: 7bit 8 | 9 | Patch 1 description 10 | 11 | Signed-off-by: Foo Bar 12 | --- 13 | file.rs | 2 +- 14 | 1 file changed, 1 insertions(+), 1 deletions(-) 15 | 16 | diff --git a/file.rs b/file.rs 17 | index abcdef..fedcba 100644 18 | --- a/file.rs 19 | +++ b/file.rs 20 | @@ -57,6 +57,6 @@ CONTEXT; 21 | context 22 | 23 | -deletion 24 | +addition 25 | 26 | context 27 | -- 28 | 2.34.1 29 | -------------------------------------------------------------------------------- /test_samples/lore_session/split_patchset/expected_patch_2.mbx: -------------------------------------------------------------------------------- 1 | Subject: [PATCH 2/3] file: Do bar 2 | From: Foo Bar 3 | Date: Tue, 16 Jul 2024 16:52:00 +0000 4 | Message-Id: <1234.567-2-foo@bar.foo.bar> 5 | MIME-Version: 1.0 6 | Content-Type: text/plain; charset="utf-8" 7 | Content-Transfer-Encoding: 7bit 8 | 9 | Patch 2 description 10 | 11 | Signed-off-by: Foo Bar 12 | --- 13 | file.rs | 2 +- 14 | 1 file changed, 1 insertions(+), 1 deletions(-) 15 | 16 | diff --git a/file.rs b/file.rs 17 | index fedcba..123456 100644 18 | --- a/file.rs 19 | +++ b/file.rs 20 | @@ -57,6 +57,6 @@ CONTEXT; 21 | context 22 | 23 | -addition 24 | +deletion 25 | 26 | context 27 | -- 28 | 2.34.1 29 | -------------------------------------------------------------------------------- /test_samples/lore_session/prepare_reply_w_reviewed_by/patch_1.mbx: -------------------------------------------------------------------------------- 1 | Subject: [PATCH 1/3] file: Do foo 2 | From: Foo Bar 3 | Date: Tue, 16 Jul 2024 16:51:00 +0000 4 | Message-Id: <1234.567-1-foo@bar.foo.bar> 5 | MIME-Version: 1.0 6 | Content-Type: text/plain; charset="utf-8" 7 | Content-Transfer-Encoding: 7bit 8 | 9 | Patch 1 description 10 | 11 | Signed-off-by: Foo Bar 12 | --- 13 | file.rs | 2 +- 14 | 1 file changed, 1 insertions(+), 1 deletions(-) 15 | 16 | diff --git a/file.rs b/file.rs 17 | index abcdef..fedcba 100644 18 | --- a/file.rs 19 | +++ b/file.rs 20 | @@ -57,6 +57,6 @@ CONTEXT; 21 | context 22 | 23 | -deletion 24 | +addition 25 | 26 | context 27 | -- 28 | 2.34.1 29 | -------------------------------------------------------------------------------- /test_samples/lore_session/prepare_reply_w_reviewed_by/patch_2.mbx: -------------------------------------------------------------------------------- 1 | Subject: [PATCH 2/3] file: Do bar 2 | From: Foo Bar 3 | Date: Tue, 16 Jul 2024 16:52:00 +0000 4 | Message-Id: <1234.567-2-foo@bar.foo.bar> 5 | MIME-Version: 1.0 6 | Content-Type: text/plain; charset="utf-8" 7 | Content-Transfer-Encoding: 7bit 8 | 9 | Patch 2 description 10 | 11 | Signed-off-by: Foo Bar 12 | --- 13 | file.rs | 2 +- 14 | 1 file changed, 1 insertions(+), 1 deletions(-) 15 | 16 | diff --git a/file.rs b/file.rs 17 | index fedcba..123456 100644 18 | --- a/file.rs 19 | +++ b/file.rs 20 | @@ -57,6 +57,6 @@ CONTEXT; 21 | context 22 | 23 | -addition 24 | +deletion 25 | 26 | context 27 | -- 28 | 2.34.1 29 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | # Formatter 5 | - id: rustfmt 6 | name: rustfmt 7 | language: rust 8 | entry: cargo fmt 9 | args: ["--all"] 10 | types: [rust] 11 | pass_filenames: false 12 | # Linter 13 | - id: clippy 14 | name: clippy 15 | language: rust 16 | entry: cargo clippy 17 | args: [ 18 | "--all-features", "--all-targets", "--tests", 19 | "--", "--allow=clippy::too-many-arguments", "--deny=warnings", 20 | "--deny=clippy::map_unwrap_or", "--deny=unconditional_recursion" 21 | ] 22 | types: [rust] 23 | pass_filenames: false 24 | -------------------------------------------------------------------------------- /test_samples/lore_session/prepare_reply_w_reviewed_by/patch_3.mbx: -------------------------------------------------------------------------------- 1 | Subject: [PATCH 3/3] file: Do foo bar 2 | From: Foo Bar 3 | Date: Tue, 16 Jul 2024 16:53:00 +0000 4 | Message-Id: <1234.567-3-foo@bar.foo.bar> 5 | MIME-Version: 1.0 6 | Content-Type: text/plain; charset="utf-8" 7 | Content-Transfer-Encoding: 7bit 8 | 9 | Patch 3 description 10 | 11 | Signed-off-by: Foo Bar 12 | --- 13 | file.rs | 2 +- 14 | 1 file changed, 1 insertions(+), 1 deletions(-) 15 | 16 | diff --git a/file.rs b/file.rs 17 | index 123456..654321 100644 18 | --- a/file.rs 19 | +++ b/file.rs 20 | @@ -57,6 +57,6 @@ CONTEXT; 21 | context 22 | 23 | -redeletion 24 | +readdition 25 | 26 | context 27 | -- 28 | 2.34.1 29 | -------------------------------------------------------------------------------- /test_samples/lore_session/split_patchset/expected_patch_3.mbx: -------------------------------------------------------------------------------- 1 | Subject: [PATCH 3/3] file: Do foo bar 2 | From: Foo Bar 3 | Date: Tue, 16 Jul 2024 16:53:00 +0000 4 | Message-Id: <1234.567-3-foo@bar.foo.bar> 5 | MIME-Version: 1.0 6 | Content-Type: text/plain; charset="utf-8" 7 | Content-Transfer-Encoding: 7bit 8 | 9 | Patch 3 description 10 | 11 | Signed-off-by: Foo Bar 12 | --- 13 | file.rs | 2 +- 14 | 1 file changed, 1 insertions(+), 1 deletions(-) 15 | 16 | diff --git a/file.rs b/file.rs 17 | index 123456..654321 100644 18 | --- a/file.rs 19 | +++ b/file.rs 20 | @@ -57,6 +57,6 @@ CONTEXT; 21 | context 22 | 23 | -redeletion 24 | +readdition 25 | 26 | context 27 | -- 28 | 2.34.1 29 | -------------------------------------------------------------------------------- /test_samples/lore_session/generate_patch_reply_template/patch_sample.mbx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Subject: [PATCH 1/3] file: Do foo 5 | From: Foo Bar 6 | Date: Tue, 16 Jul 2024 16:51:00 +0000 7 | Message-Id: <1234.567-1-foo@bar.foo.bar> 8 | MIME-Version: 1.0 9 | Content-Type: text/plain; charset="utf-8" 10 | Content-Transfer-Encoding: 7bit 11 | 12 | Patch 1 description 13 | 14 | Signed-off-by: Foo Bar 15 | --- 16 | file.rs | 2 +- 17 | 1 file changed, 1 insertions(+), 1 deletions(-) 18 | 19 | diff --git a/file.rs b/file.rs 20 | index abcdef..fedcba 100644 21 | --- a/file.rs 22 | +++ b/file.rs 23 | @@ -57,6 +57,6 @@ CONTEXT; 24 | context 25 | 26 | -deletion 27 | +addition 28 | 29 | context 30 | -- 31 | 2.34.1 32 | -------------------------------------------------------------------------------- /.github/workflows/install.yml: -------------------------------------------------------------------------------- 1 | name: Cargo Install 2 | 3 | on: 4 | push: 5 | branches: [ master, unstable, ivinjabraham-fix-124 ] 6 | pull_request: 7 | branches: [ master, unstable ] 8 | 9 | jobs: 10 | check-local-install: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Install crate 16 | run: cargo install --path . 17 | 18 | - name: Verify installed binary 19 | run: patch-hub --version 20 | 21 | # TODO: Uncomment this job after adding `patch-hub` to crates.io 22 | # check-install-from-crates-io: 23 | # runs-on: ubuntu-latest 24 | # steps: 25 | # - name: Install crate 26 | # run: cargo install patch-hub 27 | 28 | # - name: Verify installed binary 29 | # run: patch-hub -c 30 | -------------------------------------------------------------------------------- /src/lore/mailing_list.rs: -------------------------------------------------------------------------------- 1 | use derive_getters::Getters; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[cfg(test)] 5 | mod tests; 6 | 7 | #[derive(Getters, Serialize, Deserialize, Debug, Eq, PartialEq, Clone)] 8 | pub struct MailingList { 9 | name: String, 10 | description: String, 11 | } 12 | 13 | impl MailingList { 14 | pub fn new(name: &str, description: &str) -> Self { 15 | MailingList { 16 | name: name.to_string(), 17 | description: description.to_string(), 18 | } 19 | } 20 | } 21 | 22 | impl Ord for MailingList { 23 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 24 | self.name.cmp(&other.name) 25 | } 26 | } 27 | 28 | impl PartialOrd for MailingList { 29 | fn partial_cmp(&self, other: &Self) -> Option { 30 | Some(self.cmp(other)) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::ops::ControlFlow; 2 | 3 | use crate::app::App; 4 | use app::{config::Config, logging::Logger}; 5 | use clap::Parser; 6 | use cli::Cli; 7 | use handler::run_app; 8 | 9 | mod app; 10 | mod cli; 11 | mod handler; 12 | mod ui; 13 | mod utils; 14 | 15 | fn main() -> color_eyre::Result<()> { 16 | let args = Cli::parse(); 17 | 18 | utils::install_hooks()?; 19 | let mut terminal = utils::init()?; 20 | 21 | let config = Config::build(); 22 | config.create_dirs(); 23 | 24 | match args.resolve(terminal, &config) { 25 | ControlFlow::Break(b) => return b, 26 | ControlFlow::Continue(t) => terminal = t, 27 | } 28 | 29 | let app = App::new(config)?; 30 | 31 | run_app(terminal, app)?; 32 | utils::restore()?; 33 | 34 | Logger::info("patch-hub finished"); 35 | Logger::flush(); 36 | 37 | Ok(()) 38 | } 39 | -------------------------------------------------------------------------------- /.idx/dev.nix: -------------------------------------------------------------------------------- 1 | # To learn more about how to use Nix to configure your environment 2 | # see: https://developers.google.com/idx/guides/customize-idx-env 3 | { pkgs, ... }: { 4 | channel = "stable-23.11"; 5 | # Use https://search.nixos.org/packages to find packages 6 | packages = with pkgs; [ 7 | gitFull 8 | rustup 9 | b4 10 | bat 11 | delta 12 | diff-so-fancy 13 | gcc 14 | ]; 15 | 16 | # Sets environment variables in the workspace 17 | env = {}; 18 | idx = { 19 | # Search for the extensions you want on https://open-vsx.org/ and use "publisher.id" 20 | extensions = [ 21 | "franneck94.vscode-rust-extension-pack" 22 | ]; 23 | 24 | previews.enable = false; 25 | 26 | workspace = { 27 | onCreate = { 28 | rust = "rustup default stable"; 29 | first-build = "cargo build"; 30 | }; 31 | }; 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /test_samples/app/config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "page_size": 1234, 3 | "patchsets_cache_dir": "/cachedir/path", 4 | "bookmarked_patchsets_path": "/bookmarked/patchsets/path", 5 | "mailing_lists_path": "/mailing/lists/path", 6 | "reviewed_patchsets_path": "/reviewed/patchsets/path", 7 | "logs_path":"/logs/path", 8 | "git_send_email_options": "--long-option value -s -h -o -r -t", 9 | "cache_dir": "/cache_dir", 10 | "data_dir": "/data_dir", 11 | "patch_renderer": "default", 12 | "cover_renderer": "default", 13 | "max_log_age": 42, 14 | "kernel_trees": { 15 | "linux": { 16 | "path": "/home/user/linux", 17 | "branch": "master" 18 | }, 19 | "amd-gfx": { 20 | "path": "/home/user/amd-gfx", 21 | "branch": "amd-staging-drm-next" 22 | } 23 | }, 24 | "target_kernel_tree": "linux", 25 | "git_am_options": "--foo-bar foobar -s -n -o -r -l -a -x", 26 | "git_am_branch_prefix": "really-creative-prefix-" 27 | } 28 | -------------------------------------------------------------------------------- /test_samples/lore_session/process_available_lists/available_lists_response-3.html: -------------------------------------------------------------------------------- 1 | public-inbox listing
* 2024-07-22 22:53 - all
13 |   All of lore.kernel.org
14 | 

No more results, only 322	 | reverse

This is a listing of public inboxes, see the `mirror' link of each inbox
18 | for instructions on how to mirror all the data and code on this site.
-------------------------------------------------------------------------------- /src/app/logging/log_on_error.rs: -------------------------------------------------------------------------------- 1 | #[macro_export] 2 | macro_rules! log_on_error { 3 | ($result:expr) => { 4 | log_on_error!($crate::app::logging::LogLevel::Error, $result) 5 | }; 6 | ($level:expr, $result:expr) => { 7 | match $result { 8 | Ok(_) => $result, 9 | Err(ref error) => { 10 | let error_message = 11 | format!("Error executing {:?}: {}", stringify!($result), &error); 12 | match $level { 13 | $crate::app::logging::LogLevel::Info => { 14 | Logger::info(error_message); 15 | } 16 | $crate::app::logging::LogLevel::Warning => { 17 | Logger::warn(error_message); 18 | } 19 | $crate::app::logging::LogLevel::Error => { 20 | Logger::error(error_message); 21 | } 22 | } 23 | $result 24 | } 25 | } 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/ui/popup.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Debug; 2 | 3 | use ratatui::{crossterm::event::KeyEvent, layout::Rect, Frame}; 4 | 5 | pub mod help; 6 | pub mod info_popup; 7 | pub mod review_trailers; 8 | 9 | /// A trait that represents a popup that can be rendered on top of a screen 10 | pub trait PopUp: Debug { 11 | /// Returns the dimensions of the popup in percentage of the screen 12 | /// (width, height) 13 | /// 14 | /// Those dimensions are used to create the `chunk` used in the render function 15 | fn dimensions(&self) -> (u16, u16); 16 | 17 | /// Renders the popup on the given frame using the given chunk 18 | /// This chunk is a centered rectangle with the dimensions returned by `dimensions` 19 | fn render(&self, f: &mut Frame, chunk: Rect); 20 | 21 | /// Handles the key event for the popup 22 | /// 23 | /// Is important to notice that except for the 'ESC' key, all other keys are hijacked by the popup 24 | /// So the screens handlers won't be called 25 | fn handle(&mut self, key: KeyEvent) -> color_eyre::Result<()>; 26 | } 27 | -------------------------------------------------------------------------------- /src/app/screens/bookmarked.rs: -------------------------------------------------------------------------------- 1 | use patch_hub::lore::patch::Patch; 2 | 3 | pub struct BookmarkedPatchsets { 4 | pub bookmarked_patchsets: Vec, 5 | pub patchset_index: usize, 6 | } 7 | 8 | impl BookmarkedPatchsets { 9 | pub fn select_below_patchset(&mut self) { 10 | if self.patchset_index + 1 < self.bookmarked_patchsets.len() { 11 | self.patchset_index += 1; 12 | } 13 | } 14 | 15 | pub fn select_above_patchset(&mut self) { 16 | self.patchset_index = self.patchset_index.saturating_sub(1); 17 | } 18 | 19 | pub fn get_selected_patchset(&self) -> Patch { 20 | self.bookmarked_patchsets 21 | .get(self.patchset_index) 22 | .unwrap() 23 | .clone() 24 | } 25 | 26 | pub fn bookmark_selected_patch(&mut self, patch_to_bookmark: &Patch) { 27 | if !self.bookmarked_patchsets.contains(patch_to_bookmark) { 28 | self.bookmarked_patchsets.push(patch_to_bookmark.clone()); 29 | } 30 | } 31 | 32 | pub fn unbookmark_selected_patch(&mut self, patch_to_unbookmark: &Patch) { 33 | if let Some(index) = self 34 | .bookmarked_patchsets 35 | .iter() 36 | .position(|r| r == patch_to_unbookmark) 37 | { 38 | self.bookmarked_patchsets.remove(index); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::ops::ControlFlow; 2 | 3 | use clap::Parser; 4 | use color_eyre::eyre::eyre; 5 | use ratatui::{prelude::Backend, Terminal}; 6 | 7 | use crate::{app::config::Config, utils}; 8 | 9 | #[derive(Debug, Parser)] 10 | #[command(version, about)] 11 | pub struct Cli { 12 | #[clap(short = 'c', long, action)] 13 | /// Prints the current configurations to the terminal with the applied overrides 14 | pub show_configs: bool, 15 | } 16 | 17 | impl Cli { 18 | /// Resolves the command line arguments and applies the necessary changes to the terminal and app 19 | /// 20 | /// Some arguments may finish the program early (returning `ControlFlow::Break`) 21 | pub fn resolve( 22 | &self, 23 | terminal: Terminal, 24 | config: &Config, 25 | ) -> ControlFlow, Terminal> { 26 | if self.show_configs { 27 | drop(terminal); 28 | if let Err(err) = utils::restore() { 29 | return ControlFlow::Break(Err(eyre!(err))); 30 | } 31 | match serde_json::to_string_pretty(&config) { 32 | Err(err) => return ControlFlow::Break(Err(eyre!(err))), 33 | Ok(config) => println!("patch-hub configurations:\n{}", config), 34 | } 35 | 36 | return ControlFlow::Break(Ok(())); 37 | } 38 | 39 | ControlFlow::Continue(terminal) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/build_and_unit_test.yml: -------------------------------------------------------------------------------- 1 | name: Cargo Build and Unit Test 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - synchronize 8 | - reopened 9 | - ready_for_review 10 | - labeled 11 | paths: 12 | - src/** 13 | push: 14 | paths: 15 | - src/** 16 | branches: 17 | - master 18 | - unstable 19 | 20 | env: 21 | CARGO_TERM_COLOR: always 22 | 23 | jobs: 24 | build-and-unit-test: 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 2 27 | if: '!github.event.pull_request.draft' 28 | steps: 29 | - uses: actions/checkout@v4 30 | with: 31 | ref: ${{ github.event.pull_request.head.sha }} 32 | 33 | - name: Update rustup and install rustc and cargo 34 | shell: bash 35 | run: | 36 | rustup update 37 | rustup install stable 38 | 39 | - name: Check for compilation errors 40 | shell: bash 41 | run: | 42 | cargo build --verbose 43 | 44 | - name: Run unit test suites 45 | shell: bash 46 | run: | 47 | cargo test --all --all-targets --verbose && exit 0 48 | printf '\e[1;33m\t==========================================\n\e[0m' 49 | printf '\e[1;33m\tUNIT TEST SUITE FAILED\n\e[0m' 50 | printf '\e[1;33m\tPLEASE, SOLVE THEM LOCALLY W/ `cargo test`\e[0m\n' 51 | printf '\e[1;33m\t==========================================\n\e[0m' 52 | exit 1 53 | -------------------------------------------------------------------------------- /test_samples/lore_session/process_representative_patch/patch_feed_sample_1.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | John Johnson 8 | john@johnson.com 9 | 10 | [RFC/PATCH 2/2] some/subsystem: Do something else 11 | 2024-06-24T19:15:48Z 12 | 14 | 16 | 17 | 18 | 19 | 20 | 21 | John Johnson 22 | john@johnson.com 23 | 24 | [RFC/PATCH 1/2] some/subsystem: Do something 25 | 2024-06-24T19:15:48Z 26 | 28 | 30 | 31 | 32 | 33 | 34 | 35 | John Johnson 36 | john@johnson.com 37 | 38 | [RFC/PATCH 0/2] some/subsystem: Do this and that 39 | 2024-06-24T19:15:48Z 40 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /.github/workflows/format_and_lint.yml: -------------------------------------------------------------------------------- 1 | name: Format and Lint 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - synchronize 8 | - reopened 9 | - ready_for_review 10 | - labeled 11 | paths: 12 | - src/** 13 | push: 14 | paths: 15 | - src/** 16 | branches: 17 | - master 18 | - unstable 19 | 20 | jobs: 21 | format: 22 | runs-on: ubuntu-latest 23 | timeout-minutes: 2 24 | if: '!github.event.pull_request.draft' 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | ref: ${{ github.event.pull_request.head.sha }} 29 | 30 | - name: Update rustup and install rustfmt 31 | shell: bash 32 | run: | 33 | rustup update 34 | rustup component add rustfmt 35 | rustup install stable 36 | 37 | - name: Check rustfmt errors 38 | shell: bash 39 | run: | 40 | cargo fmt --all -- --check 41 | 42 | lint: 43 | runs-on: ubuntu-latest 44 | timeout-minutes: 2 45 | if: '!github.event.pull_request.draft' 46 | steps: 47 | - uses: actions/checkout@v4 48 | with: 49 | ref: ${{ github.event.pull_request.head.sha }} 50 | 51 | - name: Update rustup and install clippy 52 | shell: bash 53 | run: | 54 | rustup update 55 | rustup component add clippy 56 | rustup install stable 57 | 58 | - name: Check clippy errors 59 | shell: bash 60 | run: | 61 | cargo clippy --all-features --all-targets --tests -- --allow=clippy::too-many-arguments --deny=warnings --deny=clippy::map_unwrap_or --deny=unconditional_recursion 62 | -------------------------------------------------------------------------------- /src/app/logging/garbage_collector.rs: -------------------------------------------------------------------------------- 1 | //! Log Garbage Collector 2 | //! 3 | //! This module is responsible for cleaning up the log files. 4 | 5 | use crate::app::config::Config; 6 | 7 | use super::Logger; 8 | 9 | /// Collects the garbage from the logs directory. 10 | /// Will check for log files `patch-hub_*.log` and remove them if they are older than the `max_log_age` in the config. 11 | pub fn collect_garbage(config: &Config) { 12 | if config.max_log_age() == 0 { 13 | return; 14 | } 15 | 16 | let now = std::time::SystemTime::now(); 17 | let logs_path = config.logs_path(); 18 | let Ok(logs) = std::fs::read_dir(logs_path) else { 19 | Logger::error("Failed to read the logs directory during garbage collection"); 20 | return; 21 | }; 22 | 23 | for log in logs { 24 | let Ok(log) = log else { 25 | continue; 26 | }; 27 | let filename = log.file_name(); 28 | 29 | if !filename.to_string_lossy().ends_with(".log") 30 | || !filename.to_string_lossy().starts_with("patch-hub_") 31 | { 32 | continue; 33 | } 34 | 35 | let Ok(Ok(created_date)) = log.metadata().map(|meta| meta.created()) else { 36 | continue; 37 | }; 38 | let Ok(age) = now.duration_since(created_date) else { 39 | continue; 40 | }; 41 | let age = age.as_secs() / 60 / 60 / 24; 42 | 43 | if age as usize > config.max_log_age() && std::fs::remove_file(log.path()).is_err() { 44 | Logger::warn(format!( 45 | "Failed to remove the log file: {}", 46 | log.path().to_string_lossy() 47 | )); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "patch-hub" 3 | version = "0.1.6" 4 | edition = "2021" 5 | repository = "https://github.com/kworkflow/patch-hub" 6 | description = "patch-hub is a TUI that streamlines the interaction of Linux developers with patches archived on lore.kernel.org" 7 | 8 | [dependencies] 9 | color-eyre = "0.6.3" 10 | mockall = "0.13.1" 11 | derive-getters = { version = "0.5.0", features = ["auto_copy_getters"] } 12 | lazy_static = "1.5.0" 13 | proc_macros = { path = "./proc_macros" } 14 | ratatui = "0.29.0" 15 | regex = "1.11.1" 16 | serde = { version = "1.0.219", features = ["derive"] } 17 | serde-xml-rs = "0.6.0" 18 | serde_json = "1.0.140" 19 | thiserror = "2.0.12" 20 | clap = { version = "4.5.35", features = ["derive"] } 21 | chrono = "0.4.40" 22 | ansi-to-tui = "7.0.0" 23 | which = "7.0.2" 24 | ureq = { version = "3.0.10", features = ["rustls"] } 25 | 26 | # The profile that 'cargo dist' will build with 27 | [profile.dist] 28 | inherits = "release" 29 | lto = "thin" 30 | 31 | # Config for 'cargo dist' 32 | [workspace.metadata.dist] 33 | # The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) 34 | cargo-dist-version = "0.19.1" 35 | # CI backends to support 36 | ci = "github" 37 | # The installers to generate for each app 38 | installers = [] 39 | # Target platforms to build apps for (Rust target-triple syntax) 40 | targets = ["x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"] 41 | # Publish jobs to run in CI 42 | pr-run-mode = "plan" 43 | 44 | # Linter configurations 45 | [lints.rust] 46 | warnings = "deny" 47 | unconditional_recursion = "deny" 48 | 49 | [lints.clippy] 50 | too-many-arguments = "allow" 51 | map_unwrap_or = "deny" 52 | 53 | [workspace] 54 | members = [ 55 | "proc_macros", 56 | ] 57 | -------------------------------------------------------------------------------- /src/ui/navigation_bar.rs: -------------------------------------------------------------------------------- 1 | use super::{bookmarked, details_actions, edit_config, latest, mail_list}; 2 | use crate::app::{self, App}; 3 | use app::screens::CurrentScreen; 4 | use ratatui::{ 5 | layout::{Constraint, Direction, Layout, Rect}, 6 | text::Line, 7 | widgets::{Block, Borders, Paragraph}, 8 | Frame, 9 | }; 10 | 11 | pub fn render(f: &mut Frame, app: &App, chunk: Rect) { 12 | let mode_footer_text = match app.current_screen { 13 | CurrentScreen::MailingListSelection => mail_list::mode_footer_text(app), 14 | CurrentScreen::BookmarkedPatchsets => bookmarked::mode_footer_text(), 15 | CurrentScreen::LatestPatchsets => latest::mode_footer_text(app), 16 | CurrentScreen::PatchsetDetails => details_actions::mode_footer_text(), 17 | CurrentScreen::EditConfig => edit_config::mode_footer_text(app), 18 | }; 19 | let mode_footer = Paragraph::new(Line::from(mode_footer_text)) 20 | .block(Block::default().borders(Borders::ALL)) 21 | .centered(); 22 | 23 | let current_keys_hint = { 24 | match app.current_screen { 25 | CurrentScreen::MailingListSelection => mail_list::keys_hint(), 26 | CurrentScreen::BookmarkedPatchsets => bookmarked::keys_hint(), 27 | CurrentScreen::LatestPatchsets => latest::keys_hint(), 28 | CurrentScreen::PatchsetDetails => details_actions::keys_hint(), 29 | CurrentScreen::EditConfig => edit_config::keys_hint(app), 30 | } 31 | }; 32 | 33 | let keys_hint_footer = Paragraph::new(Line::from(current_keys_hint)) 34 | .block(Block::default().borders(Borders::ALL)) 35 | .centered(); 36 | 37 | let footer_chunks = Layout::default() 38 | .direction(Direction::Horizontal) 39 | .constraints([Constraint::Percentage(30), Constraint::Percentage(80)]) 40 | .split(chunk); 41 | 42 | f.render_widget(mode_footer, footer_chunks[0]); 43 | f.render_widget(keys_hint_footer, footer_chunks[1]); 44 | } 45 | -------------------------------------------------------------------------------- /test_samples/lore_session/split_patchset/patchset_sample_complete.mbx: -------------------------------------------------------------------------------- 1 | From git@z Thu Jan 1 00:00:00 1970 2 | Subject: [PATCH 1/3] file: Do foo 3 | From: Foo Bar 4 | Date: Tue, 16 Jul 2024 16:51:00 +0000 5 | Message-Id: <1234.567-1-foo@bar.foo.bar> 6 | MIME-Version: 1.0 7 | Content-Type: text/plain; charset="utf-8" 8 | Content-Transfer-Encoding: 7bit 9 | 10 | Patch 1 description 11 | 12 | Signed-off-by: Foo Bar 13 | --- 14 | file.rs | 2 +- 15 | 1 file changed, 1 insertions(+), 1 deletions(-) 16 | 17 | diff --git a/file.rs b/file.rs 18 | index abcdef..fedcba 100644 19 | --- a/file.rs 20 | +++ b/file.rs 21 | @@ -57,6 +57,6 @@ CONTEXT; 22 | context 23 | 24 | -deletion 25 | +addition 26 | 27 | context 28 | -- 29 | 2.34.1 30 | 31 | From git@z Thu Jan 1 00:00:00 1970 32 | Subject: [PATCH 2/3] file: Do bar 33 | From: Foo Bar 34 | Date: Tue, 16 Jul 2024 16:52:00 +0000 35 | Message-Id: <1234.567-2-foo@bar.foo.bar> 36 | MIME-Version: 1.0 37 | Content-Type: text/plain; charset="utf-8" 38 | Content-Transfer-Encoding: 7bit 39 | 40 | Patch 2 description 41 | 42 | Signed-off-by: Foo Bar 43 | --- 44 | file.rs | 2 +- 45 | 1 file changed, 1 insertions(+), 1 deletions(-) 46 | 47 | diff --git a/file.rs b/file.rs 48 | index fedcba..123456 100644 49 | --- a/file.rs 50 | +++ b/file.rs 51 | @@ -57,6 +57,6 @@ CONTEXT; 52 | context 53 | 54 | -addition 55 | +deletion 56 | 57 | context 58 | -- 59 | 2.34.1 60 | 61 | From git@z Thu Jan 1 00:00:00 1970 62 | Subject: [PATCH 3/3] file: Do foo bar 63 | From: Foo Bar 64 | Date: Tue, 16 Jul 2024 16:53:00 +0000 65 | Message-Id: <1234.567-3-foo@bar.foo.bar> 66 | MIME-Version: 1.0 67 | Content-Type: text/plain; charset="utf-8" 68 | Content-Transfer-Encoding: 7bit 69 | 70 | Patch 3 description 71 | 72 | Signed-off-by: Foo Bar 73 | --- 74 | file.rs | 2 +- 75 | 1 file changed, 1 insertions(+), 1 deletions(-) 76 | 77 | diff --git a/file.rs b/file.rs 78 | index 123456..654321 100644 79 | --- a/file.rs 80 | +++ b/file.rs 81 | @@ -57,6 +57,6 @@ CONTEXT; 82 | context 83 | 84 | -redeletion 85 | +readdition 86 | 87 | context 88 | -- 89 | 2.34.1 90 | -------------------------------------------------------------------------------- /test_samples/lore_session/split_patchset/patchset_sample_without_cover_letter.mbx: -------------------------------------------------------------------------------- 1 | From git@z Thu Jan 1 00:00:00 1970 2 | Subject: [PATCH 1/3] file: Do foo 3 | From: Foo Bar 4 | Date: Tue, 16 Jul 2024 16:51:00 +0000 5 | Message-Id: <1234.567-1-foo@bar.foo.bar> 6 | MIME-Version: 1.0 7 | Content-Type: text/plain; charset="utf-8" 8 | Content-Transfer-Encoding: 7bit 9 | 10 | Patch 1 description 11 | 12 | Signed-off-by: Foo Bar 13 | --- 14 | file.rs | 2 +- 15 | 1 file changed, 1 insertions(+), 1 deletions(-) 16 | 17 | diff --git a/file.rs b/file.rs 18 | index abcdef..fedcba 100644 19 | --- a/file.rs 20 | +++ b/file.rs 21 | @@ -57,6 +57,6 @@ CONTEXT; 22 | context 23 | 24 | -deletion 25 | +addition 26 | 27 | context 28 | -- 29 | 2.34.1 30 | 31 | From git@z Thu Jan 1 00:00:00 1970 32 | Subject: [PATCH 2/3] file: Do bar 33 | From: Foo Bar 34 | Date: Tue, 16 Jul 2024 16:52:00 +0000 35 | Message-Id: <1234.567-2-foo@bar.foo.bar> 36 | MIME-Version: 1.0 37 | Content-Type: text/plain; charset="utf-8" 38 | Content-Transfer-Encoding: 7bit 39 | 40 | Patch 2 description 41 | 42 | Signed-off-by: Foo Bar 43 | --- 44 | file.rs | 2 +- 45 | 1 file changed, 1 insertions(+), 1 deletions(-) 46 | 47 | diff --git a/file.rs b/file.rs 48 | index fedcba..123456 100644 49 | --- a/file.rs 50 | +++ b/file.rs 51 | @@ -57,6 +57,6 @@ CONTEXT; 52 | context 53 | 54 | -addition 55 | +deletion 56 | 57 | context 58 | -- 59 | 2.34.1 60 | 61 | From git@z Thu Jan 1 00:00:00 1970 62 | Subject: [PATCH 3/3] file: Do foo bar 63 | From: Foo Bar 64 | Date: Tue, 16 Jul 2024 16:53:00 +0000 65 | Message-Id: <1234.567-3-foo@bar.foo.bar> 66 | MIME-Version: 1.0 67 | Content-Type: text/plain; charset="utf-8" 68 | Content-Transfer-Encoding: 7bit 69 | 70 | Patch 3 description 71 | 72 | Signed-off-by: Foo Bar 73 | --- 74 | file.rs | 2 +- 75 | 1 file changed, 1 insertions(+), 1 deletions(-) 76 | 77 | diff --git a/file.rs b/file.rs 78 | index 123456..654321 100644 79 | --- a/file.rs 80 | +++ b/file.rs 81 | @@ -57,6 +57,6 @@ CONTEXT; 82 | context 83 | 84 | -redeletion 85 | +readdition 86 | 87 | context 88 | -- 89 | 2.34.1 90 | -------------------------------------------------------------------------------- /src/lore/mailing_list/tests.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[test] 4 | fn can_deserialize_mailing_list() { 5 | let expected_mailing_list = MailingList::new("list-name", "List Description"); 6 | let serialized_mailing_list = r#"{"name":"list-name","description":"List Description"}"#; 7 | let deserialized_mailing_list: MailingList = 8 | serde_json::from_str(serialized_mailing_list).unwrap(); 9 | 10 | assert_eq!( 11 | expected_mailing_list, deserialized_mailing_list, 12 | "Wrong deserialization of mailing list" 13 | ) 14 | } 15 | 16 | #[test] 17 | fn can_serialize_mailing_list() { 18 | let expected_serialized_mailing_list = 19 | r#"{"name":"list-name","description":"List Description"}"#; 20 | let mailing_list = MailingList::new("list-name", "List Description"); 21 | let serialized_mailing_list = serde_json::to_string(&mailing_list).unwrap(); 22 | 23 | assert_eq!( 24 | expected_serialized_mailing_list, serialized_mailing_list, 25 | "Wrong serialization of mailing list" 26 | ) 27 | } 28 | 29 | #[test] 30 | fn should_sort_mailing_list_vec() { 31 | let mut mailing_list_vec = vec![ 32 | MailingList::new("deref", "description"), 33 | MailingList::new("unit", "description"), 34 | MailingList::new("closure", "description"), 35 | MailingList::new("owner", "description"), 36 | MailingList::new("borrow", "description"), 37 | ]; 38 | let mailing_list_vec_for_cmp = mailing_list_vec.clone(); 39 | mailing_list_vec.sort(); 40 | 41 | assert_eq!( 42 | mailing_list_vec_for_cmp[4], mailing_list_vec[0], 43 | "Wrong mailing list at index 0" 44 | ); 45 | assert_eq!( 46 | mailing_list_vec_for_cmp[2], mailing_list_vec[1], 47 | "Wrong mailing list at index 1" 48 | ); 49 | assert_eq!( 50 | mailing_list_vec_for_cmp[0], mailing_list_vec[2], 51 | "Wrong mailing list at index 2" 52 | ); 53 | assert_eq!( 54 | mailing_list_vec_for_cmp[3], mailing_list_vec[3], 55 | "Wrong mailing list at index 3" 56 | ); 57 | assert_eq!( 58 | mailing_list_vec_for_cmp[1], mailing_list_vec[4], 59 | "Wrong mailing list at index 4" 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/app/cover_renderer.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::Display, 3 | io::Write, 4 | process::{Command, Stdio}, 5 | }; 6 | 7 | use serde::{Deserialize, Serialize}; 8 | 9 | use super::logging::Logger; 10 | 11 | #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default)] 12 | pub enum CoverRenderer { 13 | #[default] 14 | #[serde(rename = "default")] 15 | Default, 16 | #[serde(rename = "bat")] 17 | Bat, 18 | } 19 | 20 | impl From for CoverRenderer { 21 | fn from(value: String) -> Self { 22 | match value.as_str() { 23 | "bat" => CoverRenderer::Bat, 24 | _ => CoverRenderer::Default, 25 | } 26 | } 27 | } 28 | 29 | impl From<&str> for CoverRenderer { 30 | fn from(value: &str) -> Self { 31 | match value { 32 | "bat" => CoverRenderer::Bat, 33 | _ => CoverRenderer::Default, 34 | } 35 | } 36 | } 37 | 38 | impl Display for CoverRenderer { 39 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 40 | match self { 41 | CoverRenderer::Default => write!(f, "default"), 42 | CoverRenderer::Bat => write!(f, "bat"), 43 | } 44 | } 45 | } 46 | 47 | pub fn render_cover(raw: &str, renderer: &CoverRenderer) -> color_eyre::Result { 48 | let text = match renderer { 49 | CoverRenderer::Default => Ok(raw.to_string()), 50 | CoverRenderer::Bat => bat_cover_renderer(raw), 51 | }?; 52 | 53 | Ok(text) 54 | } 55 | 56 | /// Renders a .mbx cover using the `bat` command line tool. 57 | /// 58 | /// # Errors 59 | /// 60 | /// If bat isn't installed or if the command fails, an error will be returned. 61 | fn bat_cover_renderer(patch: &str) -> color_eyre::Result { 62 | let mut bat = Command::new("bat") 63 | .arg("-pp") 64 | .arg("-f") 65 | .arg("-l") 66 | .arg("mbx") 67 | .stdin(Stdio::piped()) 68 | .stdout(Stdio::piped()) 69 | .spawn() 70 | .map_err(|e| { 71 | Logger::error(format!("Failed to spawn bat for cover preview: {}", e)); 72 | e 73 | })?; 74 | 75 | bat.stdin.as_mut().unwrap().write_all(patch.as_bytes())?; 76 | let output = bat.wait_with_output()?; 77 | Ok(String::from_utf8(output.stdout)?) 78 | } 79 | -------------------------------------------------------------------------------- /src/handler/bookmarked.rs: -------------------------------------------------------------------------------- 1 | use std::ops::ControlFlow; 2 | 3 | use crate::{ 4 | app::{screens::CurrentScreen, App}, 5 | loading_screen, 6 | ui::popup::{help::HelpPopUpBuilder, PopUp}, 7 | }; 8 | use ratatui::{ 9 | crossterm::event::{KeyCode, KeyEvent}, 10 | prelude::Backend, 11 | Terminal, 12 | }; 13 | 14 | pub fn handle_bookmarked_patchsets( 15 | app: &mut App, 16 | key: KeyEvent, 17 | mut terminal: Terminal, 18 | ) -> color_eyre::Result>> 19 | where 20 | B: Backend + Send + 'static, 21 | { 22 | match key.code { 23 | KeyCode::Char('?') => { 24 | let popup = generate_help_popup(); 25 | app.popup = Some(popup); 26 | } 27 | KeyCode::Esc | KeyCode::Char('q') => { 28 | app.bookmarked_patchsets.patchset_index = 0; 29 | app.set_current_screen(CurrentScreen::MailingListSelection); 30 | } 31 | KeyCode::Char('j') | KeyCode::Down => { 32 | app.bookmarked_patchsets.select_below_patchset(); 33 | } 34 | KeyCode::Char('k') | KeyCode::Up => { 35 | app.bookmarked_patchsets.select_above_patchset(); 36 | } 37 | KeyCode::Enter => { 38 | terminal = loading_screen! { 39 | terminal, 40 | "Loading patchset" => { 41 | let result = app.init_details_actions(); 42 | if result.is_ok() { 43 | app.set_current_screen(CurrentScreen::PatchsetDetails); 44 | } 45 | result 46 | } 47 | }; 48 | } 49 | _ => {} 50 | } 51 | Ok(ControlFlow::Continue(terminal)) 52 | } 53 | 54 | pub fn generate_help_popup() -> Box { 55 | let popup = HelpPopUpBuilder::new() 56 | .title("Bookmarked Patchsets") 57 | .description("This screen shows all the patchsets you have bookmarked.\nThis is quite useful to keep track of patchsets you are interested in take a look later.") 58 | .keybind("ESC", "Exit") 59 | .keybind("ENTER", "See details of the selected patchset") 60 | .keybind("?", "Show this help screen") 61 | .keybind("j/🡇", "Down") 62 | .keybind("k/🡅", "Up") 63 | .build(); 64 | 65 | Box::new(popup) 66 | } 67 | -------------------------------------------------------------------------------- /src/lore/lore_api_client/tests.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use crate::lore::patch::PatchFeed; 3 | 4 | #[test] 5 | #[ignore = "network-io"] 6 | fn blocking_client_can_request_valid_patch_feed() { 7 | let lore_api_client = BlockingLoreAPIClient::default(); 8 | 9 | let patch_feed = lore_api_client.request_patch_feed("amd-gfx", 0).unwrap(); 10 | let patch_feed: PatchFeed = serde_xml_rs::from_str(&patch_feed).unwrap(); 11 | let patches = patch_feed.patches(); 12 | 13 | assert_eq!( 14 | 200, 15 | patches.len(), 16 | "Should successfully request patch feed with 200 patches" 17 | ); 18 | } 19 | 20 | #[test] 21 | #[ignore = "network-io"] 22 | fn blocking_client_should_detect_failed_patch_feed_request() { 23 | let lore_api_client = BlockingLoreAPIClient::default(); 24 | 25 | if let Err(client_error) = lore_api_client.request_patch_feed("invalid-list", 0) { 26 | match client_error { 27 | ClientError::FromUreq(_) => (), 28 | _ => { 29 | panic!("Invalid request should return non 200 OK status.\n{client_error:#?}") 30 | } 31 | } 32 | } else { 33 | panic!("Invalid request shouldn't be successful"); 34 | } 35 | 36 | if let Err(client_error) = lore_api_client.request_patch_feed("amd-gfx", 300000) { 37 | match client_error { 38 | ClientError::EndOfFeed => (), 39 | _ => { 40 | panic!("Out-of-bounds request should return end of feed.\n{client_error:#?}") 41 | } 42 | } 43 | } else { 44 | panic!("Out-of-bounds request shouldn't be successful"); 45 | } 46 | } 47 | 48 | #[test] 49 | #[ignore = "network-io"] 50 | fn blocking_client_can_request_valid_available_lists() { 51 | let lore_api_client = BlockingLoreAPIClient::default(); 52 | 53 | if lore_api_client.request_available_lists(0).is_err() { 54 | panic!("Valid request should be successful"); 55 | } 56 | } 57 | 58 | #[test] 59 | #[ignore = "network-io"] 60 | fn blocking_client_can_request_valid_patch_html() { 61 | let lore_api_client = BlockingLoreAPIClient::default(); 62 | 63 | if lore_api_client 64 | .request_patch_html("all", "Pine.LNX.4.58.0507282031180.3307@g5.osdl.org") 65 | .is_err() 66 | { 67 | panic!("Valid request should be successful"); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/ui/bookmarked.rs: -------------------------------------------------------------------------------- 1 | use crate::app; 2 | use app::screens::bookmarked::BookmarkedPatchsets; 3 | use ratatui::{ 4 | layout::Rect, 5 | style::{Color, Modifier, Style}, 6 | text::{Line, Span}, 7 | widgets::{Block, Borders, HighlightSpacing, List, ListItem, ListState}, 8 | Frame, 9 | }; 10 | 11 | pub fn render_main(f: &mut Frame, bookmarked_patchsets: &BookmarkedPatchsets, chunk: Rect) { 12 | let patchset_index = bookmarked_patchsets.patchset_index; 13 | let mut list_items = Vec::::new(); 14 | 15 | for (index, patch) in bookmarked_patchsets.bookmarked_patchsets.iter().enumerate() { 16 | let patch_title = format!("{:width$}", patch.title(), width = 70); 17 | let patch_title = format!("{:.width$}", patch_title, width = 70); 18 | let patch_author = format!("{:width$}", patch.author().name, width = 30); 19 | let patch_author = format!("{:.width$}", patch_author, width = 30); 20 | list_items.push(ListItem::new( 21 | Line::from(Span::styled( 22 | format!( 23 | "{:03}. V{:02} | #{:02} | {} | {}", 24 | index, 25 | patch.version(), 26 | patch.total_in_series(), 27 | patch_title, 28 | patch_author 29 | ), 30 | Style::default().fg(Color::Yellow), 31 | )) 32 | .centered(), 33 | )); 34 | } 35 | 36 | let list_block = Block::default() 37 | .borders(Borders::ALL) 38 | .border_type(ratatui::widgets::BorderType::Double) 39 | .style(Style::default()); 40 | 41 | let list = List::new(list_items) 42 | .block(list_block) 43 | .highlight_style( 44 | Style::default() 45 | .add_modifier(Modifier::BOLD) 46 | .add_modifier(Modifier::REVERSED) 47 | .fg(Color::Cyan), 48 | ) 49 | .highlight_symbol(">") 50 | .highlight_spacing(HighlightSpacing::Always); 51 | 52 | let mut list_state = ListState::default(); 53 | list_state.select(Some(patchset_index)); 54 | 55 | f.render_stateful_widget(list, chunk, &mut list_state); 56 | } 57 | 58 | pub fn mode_footer_text() -> Vec> { 59 | vec![Span::styled( 60 | "Bookmarked Patchsets", 61 | Style::default().fg(Color::Green), 62 | )] 63 | } 64 | 65 | pub fn keys_hint() -> Span<'static> { 66 | Span::styled( 67 | "(ESC / q) to return | (ENTER) to select | (?) help", 68 | Style::default().fg(Color::Red), 69 | ) 70 | } 71 | -------------------------------------------------------------------------------- /src/handler/edit_config.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | app::{screens::CurrentScreen, App}, 3 | ui::popup::{help::HelpPopUpBuilder, PopUp}, 4 | }; 5 | use ratatui::crossterm::event::{KeyCode, KeyEvent}; 6 | 7 | pub fn handle_edit_config(app: &mut App, key: KeyEvent) -> color_eyre::Result<()> { 8 | if let Some(edit_config_state) = app.edit_config.as_mut() { 9 | match edit_config_state.is_editing() { 10 | true => match key.code { 11 | KeyCode::Esc => { 12 | edit_config_state.clear_edit(); 13 | edit_config_state.toggle_editing(); 14 | } 15 | KeyCode::Backspace => { 16 | edit_config_state.backspace_edit(); 17 | } 18 | KeyCode::Char(ch) => { 19 | edit_config_state.append_edit(ch); 20 | } 21 | KeyCode::Enter => { 22 | edit_config_state.stage_edit(); 23 | edit_config_state.clear_edit(); 24 | edit_config_state.toggle_editing(); 25 | } 26 | _ => {} 27 | }, 28 | false => match key.code { 29 | KeyCode::Char('?') => { 30 | let popup = generate_help_popup(); 31 | app.popup = Some(popup); 32 | } 33 | KeyCode::Esc | KeyCode::Char('q') => { 34 | app.consolidate_edit_config(); 35 | app.config.save_patch_hub_config()?; 36 | app.reset_edit_config(); 37 | app.set_current_screen(CurrentScreen::MailingListSelection); 38 | } 39 | KeyCode::Enter => { 40 | edit_config_state.toggle_editing(); 41 | } 42 | KeyCode::Char('j') | KeyCode::Down => { 43 | edit_config_state.highlight_next(); 44 | } 45 | KeyCode::Char('k') | KeyCode::Up => { 46 | edit_config_state.highlight_prev(); 47 | } 48 | _ => {} 49 | }, 50 | } 51 | } 52 | Ok(()) 53 | } 54 | 55 | // TODO: Move this to a more appropriate place 56 | pub fn generate_help_popup() -> Box { 57 | let popup = HelpPopUpBuilder::new() 58 | .title("Edit Config") 59 | .description("This screen allows you to edit the configuration options for patch-hub.\nMore configurations may be available in the configuration file.") 60 | .keybind("ESC", "Exit") 61 | .keybind("ENTER", "Save changes") 62 | .keybind("?", "Show this help screen") 63 | .keybind("j/🡇", "Down") 64 | .keybind("k/🡅", "Up") 65 | .keybind("e", "Toggle editing for a configuration option") 66 | .build(); 67 | 68 | Box::new(popup) 69 | } 70 | -------------------------------------------------------------------------------- /src/app/screens/mail_list.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::eyre::bail; 2 | use patch_hub::lore::{ 3 | lore_api_client::BlockingLoreAPIClient, lore_session, mailing_list::MailingList, 4 | }; 5 | 6 | pub struct MailingListSelection { 7 | pub mailing_lists: Vec, 8 | pub target_list: String, 9 | pub possible_mailing_lists: Vec, 10 | pub highlighted_list_index: usize, 11 | pub mailing_lists_path: String, 12 | pub lore_api_client: BlockingLoreAPIClient, 13 | } 14 | 15 | impl MailingListSelection { 16 | pub fn refresh_available_mailing_lists(&mut self) -> color_eyre::Result<()> { 17 | match lore_session::fetch_available_lists(&self.lore_api_client) { 18 | Ok(available_mailing_lists) => { 19 | self.mailing_lists = available_mailing_lists; 20 | } 21 | Err(failed_available_lists_request) => { 22 | bail!(format!("{failed_available_lists_request:#?}")); 23 | } 24 | }; 25 | 26 | self.clear_target_list(); 27 | 28 | lore_session::save_available_lists(&self.mailing_lists, &self.mailing_lists_path)?; 29 | 30 | Ok(()) 31 | } 32 | 33 | pub fn remove_last_target_list_char(&mut self) { 34 | if !self.target_list.is_empty() { 35 | self.target_list.pop(); 36 | self.process_possible_mailing_lists(); 37 | } 38 | } 39 | 40 | pub fn push_char_to_target_list(&mut self, ch: char) { 41 | self.target_list.push(ch); 42 | self.process_possible_mailing_lists(); 43 | } 44 | 45 | pub fn clear_target_list(&mut self) { 46 | self.target_list.clear(); 47 | self.process_possible_mailing_lists(); 48 | } 49 | 50 | fn process_possible_mailing_lists(&mut self) { 51 | let mut possible_mailing_lists: Vec = Vec::new(); 52 | 53 | for mailing_list in &self.mailing_lists { 54 | if mailing_list.name().starts_with(&self.target_list) { 55 | possible_mailing_lists.push(mailing_list.clone()); 56 | } 57 | } 58 | 59 | self.possible_mailing_lists = possible_mailing_lists; 60 | self.highlighted_list_index = 0; 61 | } 62 | 63 | pub fn highlight_below_list(&mut self) { 64 | if self.highlighted_list_index + 1 < self.possible_mailing_lists.len() { 65 | self.highlighted_list_index += 1; 66 | } 67 | } 68 | 69 | pub fn highlight_above_list(&mut self) { 70 | self.highlighted_list_index = self.highlighted_list_index.saturating_sub(1); 71 | } 72 | 73 | pub fn has_valid_target_list(&self) -> bool { 74 | let list_length = self.possible_mailing_lists.len(); // Possible mailing list length 75 | let list_index = self.highlighted_list_index; // Index of the selected mailing list 76 | 77 | if list_index < list_length { 78 | return true; 79 | } 80 | false 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/handler/latest.rs: -------------------------------------------------------------------------------- 1 | use std::ops::ControlFlow; 2 | 3 | use crate::{ 4 | app::{screens::CurrentScreen, App}, 5 | loading_screen, 6 | ui::popup::{help::HelpPopUpBuilder, PopUp}, 7 | }; 8 | use ratatui::{ 9 | crossterm::event::{KeyCode, KeyEvent}, 10 | prelude::Backend, 11 | Terminal, 12 | }; 13 | 14 | pub fn handle_latest_patchsets( 15 | app: &mut App, 16 | key: KeyEvent, 17 | mut terminal: Terminal, 18 | ) -> color_eyre::Result>> 19 | where 20 | B: Backend + Send + 'static, 21 | { 22 | let latest_patchsets = app.latest_patchsets.as_mut().unwrap(); 23 | 24 | match key.code { 25 | KeyCode::Char('?') => { 26 | let popup = generate_help_popup(); 27 | app.popup = Some(popup); 28 | } 29 | KeyCode::Esc | KeyCode::Char('q') => { 30 | app.reset_latest_patchsets(); 31 | app.set_current_screen(CurrentScreen::MailingListSelection); 32 | } 33 | KeyCode::Char('j') | KeyCode::Down => { 34 | latest_patchsets.select_below_patchset(); 35 | } 36 | KeyCode::Char('k') | KeyCode::Up => { 37 | latest_patchsets.select_above_patchset(); 38 | } 39 | KeyCode::Char('l') | KeyCode::Right => { 40 | let list_name = latest_patchsets.target_list().to_string(); 41 | terminal = loading_screen! { 42 | terminal, 43 | format!("Fetching patchsets from {}", list_name) => { 44 | latest_patchsets.increment_page(); 45 | latest_patchsets.fetch_current_page() 46 | } 47 | }; 48 | } 49 | KeyCode::Char('h') | KeyCode::Left => { 50 | latest_patchsets.decrement_page(); 51 | } 52 | KeyCode::Enter => { 53 | terminal = loading_screen! { 54 | terminal, 55 | "Loading patchset" => { 56 | let result = app.init_details_actions(); 57 | if result.is_ok() { 58 | app.set_current_screen(CurrentScreen::PatchsetDetails); 59 | } 60 | result 61 | } 62 | }; 63 | } 64 | _ => {} 65 | } 66 | Ok(ControlFlow::Continue(terminal)) 67 | } 68 | 69 | pub fn generate_help_popup() -> Box { 70 | let popup = HelpPopUpBuilder::new() 71 | .title("Latest Patchsets") 72 | .description("This screen allows you to see a list of the latest patchsets from a mailing list.\nYou might also be able to view the details of a patchset.") 73 | .keybind("ESC", "Exit") 74 | .keybind("ENTER", "See details of the selected patchset") 75 | .keybind("?", "Show this help screen") 76 | .keybind("j/🡇", "Down") 77 | .keybind("k/🡅", "Up") 78 | .keybind("l/🡆", "Next page") 79 | .keybind("h/🡄", "Previous page") 80 | .build(); 81 | Box::new(popup) 82 | } 83 | -------------------------------------------------------------------------------- /src/ui/edit_config.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Constraint, Direction, Layout, Rect}, 3 | style::{Color, Modifier, Style}, 4 | text::{Line, Span}, 5 | widgets::{Block, Borders, Paragraph}, 6 | Frame, 7 | }; 8 | 9 | use crate::app::logging::Logger; 10 | use crate::app::App; 11 | 12 | pub fn render_main(f: &mut Frame, app: &App, chunk: Rect) { 13 | let edit_config = app.edit_config.as_ref().unwrap(); 14 | let mut constraints = Vec::new(); 15 | 16 | for _ in 0..(chunk.height / 3) { 17 | constraints.push(Constraint::Length(3)); 18 | } 19 | 20 | let config_chunks = Layout::default() 21 | .direction(Direction::Vertical) 22 | .constraints(constraints) 23 | .split(chunk); 24 | 25 | let highlighted_entry = edit_config.highlighted(); 26 | for i in 0..edit_config.config_count() { 27 | if i + 1 > config_chunks.len() { 28 | break; 29 | } 30 | 31 | let (config, value) = match edit_config.config(i) { 32 | Some((cfg, val)) => (cfg, val), 33 | None => { 34 | Logger::error(format!("Invalid configuration index: {}", i)); 35 | return; 36 | } 37 | }; 38 | 39 | let value = Line::from(if edit_config.is_editing() && i == highlighted_entry { 40 | vec![ 41 | Span::styled(edit_config.curr_edit().to_string(), Style::default()), 42 | Span::styled(" ", Style::default().bg(Color::White)), 43 | ] 44 | } else { 45 | vec![Span::from(value)] 46 | }); 47 | 48 | let config_entry = Paragraph::new(value) 49 | .centered() 50 | .block(Block::default().borders(Borders::ALL).title(config)) 51 | .style(if i == highlighted_entry && edit_config.is_editing() { 52 | Style::default() 53 | .fg(Color::LightYellow) 54 | .add_modifier(Modifier::BOLD) 55 | } else if i == highlighted_entry { 56 | Style::default() 57 | .fg(Color::DarkGray) 58 | .add_modifier(Modifier::BOLD) 59 | } else { 60 | Style::default() 61 | }); 62 | 63 | f.render_widget(config_entry, config_chunks[i]); 64 | } 65 | } 66 | 67 | pub fn mode_footer_text(app: &App) -> Vec { 68 | let edit_config_state = app.edit_config.as_ref().unwrap(); 69 | vec![if edit_config_state.is_editing() { 70 | Span::styled("Editing...", Style::default().fg(Color::LightYellow)) 71 | } else { 72 | Span::styled("Edit Configurations", Style::default().fg(Color::Green)) 73 | }] 74 | } 75 | 76 | pub fn keys_hint(app: &App) -> Span { 77 | let edit_config_state = app.edit_config.as_ref().unwrap(); 78 | match edit_config_state.is_editing() { 79 | true => Span::styled( 80 | "(ESC) cancel | (ENTER) confirm", 81 | Style::default().fg(Color::Red), 82 | ), 83 | false => Span::styled( 84 | "(ESC / q) exit | (ENTER) edit | (jk| 🡇 🡅 ) down up", 85 | Style::default().fg(Color::Red), 86 | ), 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/ui/latest.rs: -------------------------------------------------------------------------------- 1 | use crate::app::App; 2 | use patch_hub::lore::patch::Patch; 3 | use ratatui::{ 4 | layout::Rect, 5 | style::{Color, Modifier, Style}, 6 | text::{Line, Span}, 7 | widgets::{Block, Borders, HighlightSpacing, List, ListItem, ListState}, 8 | Frame, 9 | }; 10 | 11 | pub fn render_main(f: &mut Frame, app: &App, chunk: Rect) { 12 | let page_number = app.latest_patchsets.as_ref().unwrap().page_number(); 13 | let patchset_index = app.latest_patchsets.as_ref().unwrap().patchset_index(); 14 | let mut list_items = Vec::::new(); 15 | 16 | let patch_feed_page: Vec<&Patch> = app 17 | .latest_patchsets 18 | .as_ref() 19 | .unwrap() 20 | .get_current_patch_feed_page() 21 | .unwrap(); 22 | 23 | let mut index: usize = (page_number - 1) * app.config.page_size(); 24 | for patch in patch_feed_page { 25 | let patch_title = format!("{:width$}", patch.title(), width = 70); 26 | let patch_title = format!("{:.width$}", patch_title, width = 70); 27 | let patch_author = format!("{:width$}", patch.author().name, width = 30); 28 | let patch_author = format!("{:.width$}", patch_author, width = 30); 29 | list_items.push(ListItem::new( 30 | Line::from(Span::styled( 31 | format!( 32 | "{:03}. V{:02} | #{:02} | {} | {}", 33 | index, 34 | patch.version(), 35 | patch.total_in_series(), 36 | patch_title, 37 | patch_author 38 | ), 39 | Style::default().fg(Color::Yellow), 40 | )) 41 | .centered(), 42 | )); 43 | index += 1; 44 | } 45 | 46 | let list_block = Block::default() 47 | .borders(Borders::ALL) 48 | .border_type(ratatui::widgets::BorderType::Double) 49 | .style(Style::default()); 50 | 51 | let list = List::new(list_items) 52 | .block(list_block) 53 | .highlight_style( 54 | Style::default() 55 | .add_modifier(Modifier::BOLD) 56 | .add_modifier(Modifier::REVERSED) 57 | .fg(Color::Cyan), 58 | ) 59 | .highlight_symbol(">") 60 | .highlight_spacing(HighlightSpacing::Always); 61 | 62 | let mut list_state = ListState::default(); 63 | list_state.select(Some( 64 | patchset_index - (page_number - 1) * app.config.page_size(), 65 | )); 66 | 67 | f.render_stateful_widget(list, chunk, &mut list_state); 68 | } 69 | 70 | pub fn mode_footer_text(app: &App) -> Vec { 71 | vec![Span::styled( 72 | format!( 73 | "Latest Patchsets from {} (page {})", 74 | &app.latest_patchsets.as_ref().unwrap().target_list(), 75 | &app.latest_patchsets.as_ref().unwrap().page_number() 76 | ), 77 | Style::default().fg(Color::Green), 78 | )] 79 | } 80 | 81 | pub fn keys_hint() -> Span<'static> { 82 | Span::styled( 83 | "(ESC / q) to return | (ENTER) to select | ( h / 🡄 ) previous page | ( l / 🡆 ) next page | (?) help", 84 | Style::default().fg(Color::Red), 85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /src/ui.rs: -------------------------------------------------------------------------------- 1 | use crate::app::{screens::CurrentScreen, App}; 2 | use ratatui::{ 3 | layout::{Alignment, Constraint, Direction, Layout, Rect}, 4 | style::{Color, Style, Stylize}, 5 | text::Text, 6 | widgets::{Block, Borders, Clear, Paragraph}, 7 | Frame, 8 | }; 9 | 10 | mod bookmarked; 11 | mod details_actions; 12 | mod edit_config; 13 | mod latest; 14 | pub mod loading_screen; 15 | mod mail_list; 16 | mod navigation_bar; 17 | pub mod popup; 18 | 19 | pub fn draw_ui(f: &mut Frame, app: &App) { 20 | // Clear the whole screen for sanitizing reasons 21 | f.render_widget(Clear, f.area()); 22 | 23 | let chunks = Layout::default() 24 | .direction(Direction::Vertical) 25 | .constraints([ 26 | Constraint::Length(3), 27 | Constraint::Min(1), 28 | Constraint::Length(3), 29 | ]) 30 | .split(f.area()); 31 | 32 | render_title(f, chunks[0]); 33 | 34 | match app.current_screen { 35 | CurrentScreen::MailingListSelection => mail_list::render_main(f, app, chunks[1]), 36 | CurrentScreen::BookmarkedPatchsets => { 37 | bookmarked::render_main(f, &app.bookmarked_patchsets, chunks[1]) 38 | } 39 | CurrentScreen::LatestPatchsets => latest::render_main(f, app, chunks[1]), 40 | CurrentScreen::PatchsetDetails => details_actions::render_main(f, app, chunks[1]), 41 | CurrentScreen::EditConfig => edit_config::render_main(f, app, chunks[1]), 42 | } 43 | 44 | navigation_bar::render(f, app, chunks[2]); 45 | 46 | app.popup.as_ref().inspect(|p| { 47 | let (x, y) = p.dimensions(); 48 | let rect = centered_rect(x, y, f.area()); 49 | p.render(f, rect); 50 | }); 51 | } 52 | 53 | fn render_title(f: &mut Frame, chunk: Rect) { 54 | let title_block = Block::default() 55 | .borders(Borders::ALL) 56 | .style(Style::default()) 57 | .title_alignment(Alignment::Center); 58 | 59 | let title_content: String = "patch-hub".to_string(); 60 | 61 | let title = Paragraph::new(Text::styled( 62 | title_content, 63 | Style::default().fg(Color::Green).bold(), 64 | )) 65 | .centered() 66 | .block(title_block); 67 | 68 | f.render_widget(title, chunk); 69 | } 70 | 71 | /// helper function to create a centered rect using up certain percentage of the available rect `r` 72 | fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 73 | // Cut the given rectangle into three vertical pieces 74 | let popup_layout = Layout::default() 75 | .direction(Direction::Vertical) 76 | .constraints([ 77 | Constraint::Percentage((100 - percent_y) / 2), 78 | Constraint::Percentage(percent_y), 79 | Constraint::Percentage((100 - percent_y) / 2), 80 | ]) 81 | .split(r); 82 | 83 | // Then cut the middle vertical piece into three width-wise pieces 84 | Layout::default() 85 | .direction(Direction::Horizontal) 86 | .constraints([ 87 | Constraint::Percentage((100 - percent_x) / 2), 88 | Constraint::Percentage(percent_x), 89 | Constraint::Percentage((100 - percent_x) / 2), 90 | ]) 91 | .split(popup_layout[1])[1] // Return the middle chunk 92 | } 93 | -------------------------------------------------------------------------------- /test_samples/lore_session/process_representative_patch/patch_feed_sample_2.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | Roberto Silva 8 | roberto@silva.br 9 | 10 | [Patch v3] another/subsystem: Do almost nothing 11 | 2024-06-25T09:19:58Z 12 | 14 | 15 | 16 | 17 | 18 | 19 | Lima Luma 20 | lima@luma.rs 21 | 22 | [GSoC][V18 patch 3/9] new/sub/system: Fix that part 23 | 2024-06-25T03:23:47Z 24 | 26 | 28 | 29 | 30 | 31 | 32 | 33 | Lima Luma 34 | lima@luma.rs 35 | 36 | [GSoC][V18 patch 2/9] new/sub/system: Assess problems 37 | 2024-06-25T03:23:13Z 38 | 40 | 42 | 43 | 44 | 45 | 46 | 47 | Lima Luma 48 | lima@luma.rs 49 | 50 | [GSoC][V18 patch 1/9] new/sub/system: Fix foo 51 | 2024-06-25T03:22:01Z 52 | 54 | 56 | 57 | 58 | 59 | 60 | 61 | John Johnson 62 | john@johnson.com 63 | 64 | [RFC/PATCH 2/2] some/subsystem: Do something else 65 | 2024-06-24T19:15:48Z 66 | 68 | 70 | 71 | 72 | 73 | 74 | 75 | John Johnson 76 | john@johnson.com 77 | 78 | [RFC/PATCH 1/2] some/subsystem: Do something 79 | 2024-06-24T19:15:48Z 80 | 82 | 84 | 85 | 86 | 87 | 88 | 89 | John Johnson 90 | john@johnson.com 91 | 92 | [RFC/PATCH 0/2] some/subsystem: Do this and that 93 | 2024-06-24T19:15:48Z 94 | 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /src/ui/mail_list.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::Rect, 3 | style::{Color, Modifier, Style}, 4 | text::{Line, Span}, 5 | widgets::{Block, Borders, HighlightSpacing, List, ListItem, ListState}, 6 | Frame, 7 | }; 8 | 9 | use crate::app::App; 10 | 11 | pub fn render_main(f: &mut Frame, app: &App, chunk: Rect) { 12 | let highlighted_list_index = app.mailing_list_selection.highlighted_list_index; 13 | let mut list_items = Vec::::new(); 14 | 15 | for mailing_list in &app.mailing_list_selection.possible_mailing_lists { 16 | list_items.push(ListItem::new( 17 | Line::from(vec![ 18 | Span::styled( 19 | mailing_list.name().to_string(), 20 | Style::default().fg(Color::Magenta), 21 | ), 22 | Span::styled( 23 | format!(" - {}", mailing_list.description()), 24 | Style::default().fg(Color::White), 25 | ), 26 | ]) 27 | .centered(), 28 | )) 29 | } 30 | 31 | let list_block = Block::default() 32 | .borders(Borders::ALL) 33 | .border_type(ratatui::widgets::BorderType::Double) 34 | .style(Style::default()); 35 | 36 | let list = List::new(list_items) 37 | .block(list_block) 38 | .highlight_style( 39 | Style::default() 40 | .add_modifier(Modifier::BOLD) 41 | .add_modifier(Modifier::REVERSED) 42 | .fg(Color::Cyan), 43 | ) 44 | .highlight_symbol(">") 45 | .highlight_spacing(HighlightSpacing::Always); 46 | 47 | let mut list_state = ListState::default(); 48 | list_state.select(Some(highlighted_list_index)); 49 | 50 | f.render_stateful_widget(list, chunk, &mut list_state); 51 | } 52 | 53 | pub fn mode_footer_text(app: &App) -> Vec { 54 | let mut text_area = Span::default(); 55 | 56 | if app.mailing_list_selection.target_list.is_empty() { 57 | text_area = Span::styled("type the target list", Style::default().fg(Color::DarkGray)) 58 | } else { 59 | for mailing_list in &app.mailing_list_selection.mailing_lists { 60 | if mailing_list 61 | .name() 62 | .eq(&app.mailing_list_selection.target_list) 63 | { 64 | text_area = Span::styled( 65 | &app.mailing_list_selection.target_list, 66 | Style::default().fg(Color::Green), 67 | ); 68 | break; 69 | } else if mailing_list 70 | .name() 71 | .starts_with(&app.mailing_list_selection.target_list) 72 | { 73 | text_area = Span::styled( 74 | &app.mailing_list_selection.target_list, 75 | Style::default().fg(Color::LightCyan), 76 | ); 77 | } 78 | } 79 | if text_area.content.is_empty() { 80 | text_area = Span::styled( 81 | &app.mailing_list_selection.target_list, 82 | Style::default().fg(Color::Red), 83 | ); 84 | } 85 | } 86 | 87 | vec![ 88 | Span::styled("Target List: ", Style::default().fg(Color::Green)), 89 | text_area, 90 | ] 91 | } 92 | 93 | pub fn keys_hint() -> Span<'static> { 94 | Span::styled( 95 | "(ESC) to quit | (ENTER) to confirm | (?) help", 96 | Style::default().fg(Color::Red), 97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /src/ui/popup/info_popup.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | crossterm::event::KeyCode, 3 | layout::{Alignment, Rect}, 4 | style::{Color, Modifier, Style}, 5 | text::Line, 6 | widgets::{Block, BorderType, Borders, Clear, Paragraph, Wrap}, 7 | }; 8 | 9 | use super::PopUp; 10 | 11 | #[derive(Debug)] 12 | pub struct InfoPopUp { 13 | title: String, 14 | info: String, 15 | offset: (u16, u16), 16 | max_offset: (u16, u16), 17 | dimensions: (u16, u16), 18 | } 19 | 20 | impl InfoPopUp { 21 | /// Generate a pop-up with a title and an arbitrary information. 22 | pub fn generate_info_popup(title: &str, info: &str) -> Box { 23 | let mut lines = 0; 24 | let mut columns = 0; 25 | 26 | for line in info.lines() { 27 | lines += 1; 28 | let line_len = line.len() as u16; 29 | if line_len > columns { 30 | columns = line_len; 31 | } 32 | } 33 | 34 | let dimensions = (30, 50); // TODO: Calculate percentage based on the lines and screen size 35 | 36 | Box::new(InfoPopUp { 37 | title: title.to_string(), 38 | info: info.to_string(), 39 | offset: (0, 0), 40 | max_offset: (lines, columns), 41 | dimensions, 42 | }) 43 | } 44 | } 45 | 46 | impl PopUp for InfoPopUp { 47 | fn dimensions(&self) -> (u16, u16) { 48 | self.dimensions 49 | } 50 | 51 | /// Renders a centered overlaying pop-up with a title and an arbitrary info. 52 | fn render(&self, f: &mut ratatui::Frame, chunk: Rect) { 53 | let bold_blue = Style::default() 54 | .add_modifier(Modifier::BOLD) 55 | .fg(Color::Blue); 56 | let block = Block::default() 57 | .title(self.title.clone()) 58 | .title_alignment(Alignment::Center) 59 | .title_style(bold_blue) 60 | .title_bottom(Line::styled("(ESC / q) Close", bold_blue)) 61 | .borders(Borders::ALL) 62 | .border_type(BorderType::Double) 63 | .style(Style::default()); 64 | 65 | let pop_up = Paragraph::new(self.info.clone()) 66 | .block(block) 67 | .alignment(Alignment::Left) 68 | .wrap(Wrap { trim: true }) 69 | .scroll(self.offset); 70 | 71 | f.render_widget(Clear, chunk); 72 | f.render_widget(pop_up, chunk); 73 | } 74 | 75 | /// Handles simple one-char width navigation. 76 | fn handle(&mut self, key: ratatui::crossterm::event::KeyEvent) -> color_eyre::Result<()> { 77 | match key.code { 78 | KeyCode::Up | KeyCode::Char('k') => { 79 | if self.offset.0 > 0 { 80 | self.offset.0 -= 1; 81 | } 82 | } 83 | KeyCode::Down | KeyCode::Char('j') => { 84 | if self.offset.0 < self.max_offset.0 { 85 | self.offset.0 += 1; 86 | } 87 | } 88 | KeyCode::Left | KeyCode::Char('h') => { 89 | if self.offset.1 > 0 { 90 | self.offset.1 -= 1; 91 | } 92 | } 93 | KeyCode::Right | KeyCode::Char('l') => { 94 | if self.offset.1 < self.max_offset.1 { 95 | self.offset.1 += 1; 96 | } 97 | } 98 | _ => {} 99 | } 100 | 101 | Ok(()) 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/app/screens/latest.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::eyre::bail; 2 | use derive_getters::Getters; 3 | use patch_hub::lore::{ 4 | lore_api_client::{BlockingLoreAPIClient, ClientError}, 5 | lore_session::{LoreSession, LoreSessionError}, 6 | patch::Patch, 7 | }; 8 | 9 | #[derive(Getters)] 10 | pub struct LatestPatchsets { 11 | lore_session: LoreSession, 12 | lore_api_client: BlockingLoreAPIClient, 13 | target_list: String, 14 | page_number: usize, 15 | patchset_index: usize, 16 | page_size: usize, 17 | } 18 | 19 | impl LatestPatchsets { 20 | pub fn new( 21 | target_list: String, 22 | page_size: usize, 23 | lore_api_client: BlockingLoreAPIClient, 24 | ) -> LatestPatchsets { 25 | LatestPatchsets { 26 | lore_session: LoreSession::new(target_list.clone()), 27 | lore_api_client, 28 | target_list, 29 | page_number: 1, 30 | patchset_index: 0, 31 | page_size, 32 | } 33 | } 34 | 35 | pub fn fetch_current_page(&mut self) -> color_eyre::Result<()> { 36 | if let Err(lore_session_error) = self.lore_session.process_n_representative_patches( 37 | &self.lore_api_client, 38 | self.page_size * self.page_number, 39 | ) { 40 | match lore_session_error { 41 | LoreSessionError::FromLoreAPIClient(client_error) => match client_error { 42 | ClientError::FromUreq(_) => { 43 | bail!("Failed to request feed\n{client_error:#?}") 44 | } 45 | ClientError::EndOfFeed => (), 46 | }, 47 | } 48 | }; 49 | Ok(()) 50 | } 51 | 52 | pub fn select_below_patchset(&mut self) { 53 | if self.patchset_index + 1 < self.lore_session.representative_patches_ids().len() 54 | && self.patchset_index + 1 < self.page_size * self.page_number 55 | { 56 | self.patchset_index += 1; 57 | } 58 | } 59 | 60 | pub fn select_above_patchset(&mut self) { 61 | if self.patchset_index == 0 { 62 | return; 63 | } 64 | if self.patchset_index > self.page_size * (&self.page_number - 1) { 65 | self.patchset_index -= 1; 66 | } 67 | } 68 | 69 | pub fn increment_page(&mut self) { 70 | let patchsets_processed: usize = self.lore_session.representative_patches_ids().len(); 71 | if self.page_size * self.page_number > patchsets_processed { 72 | return; 73 | } 74 | self.page_number += 1; 75 | self.patchset_index = self.page_size * (&self.page_number - 1); 76 | } 77 | 78 | pub fn decrement_page(&mut self) { 79 | if self.page_number == 1 { 80 | return; 81 | } 82 | self.page_number -= 1; 83 | self.patchset_index = self.page_size * (&self.page_number - 1); 84 | } 85 | 86 | pub fn get_selected_patchset(&self) -> Patch { 87 | let message_id: &str = self 88 | .lore_session 89 | .representative_patches_ids() 90 | .get(self.patchset_index) 91 | .unwrap(); 92 | 93 | self.lore_session 94 | .get_processed_patch(message_id) 95 | .unwrap() 96 | .clone() 97 | } 98 | 99 | pub fn get_current_patch_feed_page(&self) -> Option> { 100 | self.lore_session 101 | .get_patch_feed_page(self.page_size, self.page_number) 102 | } 103 | 104 | pub fn processed_patchsets_count(&self) -> usize { 105 | self.lore_session.representative_patches_ids().len() 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /test_samples/lore_session/extract_git_reply_command/patch_lore_sample.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | [RFC/PATCH 2/2] some/subsystem: Do something else 5 | 6 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
amd-gfx.lists.freedesktop.org archive mirror
 24 |  help / color / mirror / Atom feed
34 |
35 |
From: John Johnson
 36 | To: foo@bar.com, bar@foo.com
 37 | Cc: foo@list.org, bar@list.org
 38 | Subject: [PATCH 0/9] drm: Use backlight power constants
 41 | Date: Some Date	[thread overview]
 43 | Message-ID: 1234.567-3-john@johnson.com (raw)
 44 | 
 45 | Do foo with bar.
 46 | 
 47 | Also, don't foobar.
 48 | 
 49 | changes
 50 | 
 51 | -- 
 52 | 2.45.2
 53 | 
 54 | 
55 |
56 |
             reply	other threads:[~2024-07-31 12:23 UTC|newest]
 62 | 
 63 | Thread overview: 12+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
 69 | 2024-07-31 12:17 John Johnson [this message]
 72 | 2024-07-31 12:17 ` [PATCH 0/2] some/subsystem: Foo Bar John Johnson
 74 | 2024-07-31 12:17 ` [PATCH 1/2] some/subsystem: Bar " John Johnson
 76 | 
77 |
78 |
Reply instructions:
 79 | 
 80 | You may reply publicly to this message via plain-text email
 82 | using any one of the following methods:
 83 | 
 84 | * Save the following mbox file, import it into your mail client,
 85 |   and reply-to-all from there: mbox
 87 | 
 88 |   Avoid top-posting and favor interleaved quoting:
 89 |   https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
 91 | 
 92 | * Reply using the --to, --cc, and --in-reply-to
 93 |   switches of git-send-email(1):
 94 | 
 95 |   git send-email \
 96 |     --in-reply-to=1234.567-3-john@johnson.com \
 97 |     --to=foo@bar.com \
 98 |     --cc=bar@foo.com \
 99 |     --cc=foo@list.org \
100 |     --cc=bar@list.org \
101 |     /path/to/YOUR_REPLY
102 | 
103 |   https://kernel.org/pub/software/scm/git/docs/git-send-email.html
105 | 
106 | * If your mail client supports setting the In-Reply-To header
107 |   via mailto: links, try the mailto: link
109 | 
110 | 111 | Be sure your reply has a Subject: header at the top and a blank line 112 | before the message body. 113 |
114 |
This is a public inbox, see mirroring instructions
116 | for how to clone and mirror all data and code used for this inbox;
117 | as well as URLs for NNTP newsgroup(s).
118 | 119 | 120 | -------------------------------------------------------------------------------- /src/handler/mail_list.rs: -------------------------------------------------------------------------------- 1 | use std::ops::ControlFlow; 2 | 3 | use crate::{ 4 | app::{screens::CurrentScreen, App}, 5 | loading_screen, 6 | ui::popup::{help::HelpPopUpBuilder, PopUp}, 7 | }; 8 | use ratatui::{ 9 | crossterm::event::{KeyCode, KeyEvent}, 10 | prelude::Backend, 11 | Terminal, 12 | }; 13 | 14 | pub fn handle_mailing_list_selection( 15 | app: &mut App, 16 | key: KeyEvent, 17 | mut terminal: Terminal, 18 | ) -> color_eyre::Result>> 19 | where 20 | B: Backend + Send + 'static, 21 | { 22 | match key.code { 23 | KeyCode::Char('?') => { 24 | let popup = generate_help_popup(); 25 | app.popup = Some(popup); 26 | } 27 | KeyCode::Enter => { 28 | if app.mailing_list_selection.has_valid_target_list() { 29 | app.init_latest_patchsets(); 30 | let list_name = app 31 | .latest_patchsets 32 | .as_ref() 33 | .unwrap() 34 | .target_list() 35 | .to_string(); 36 | 37 | terminal = loading_screen! { 38 | terminal, 39 | format!("Fetching patchsets from {}", list_name) => { 40 | let result = 41 | app.latest_patchsets.as_mut().unwrap() 42 | .fetch_current_page(); 43 | if result.is_ok() { 44 | app.mailing_list_selection.clear_target_list(); 45 | app.set_current_screen(CurrentScreen::LatestPatchsets); 46 | } 47 | result 48 | } 49 | }; 50 | } 51 | } 52 | KeyCode::F(5) => { 53 | terminal = loading_screen! { 54 | terminal, 55 | "Refreshing lists" => { 56 | app.mailing_list_selection 57 | .refresh_available_mailing_lists() 58 | } 59 | }; 60 | } 61 | KeyCode::F(2) => { 62 | app.init_edit_config(); 63 | app.set_current_screen(CurrentScreen::EditConfig); 64 | } 65 | KeyCode::F(1) => { 66 | if !app.bookmarked_patchsets.bookmarked_patchsets.is_empty() { 67 | app.mailing_list_selection.clear_target_list(); 68 | app.set_current_screen(CurrentScreen::BookmarkedPatchsets); 69 | } 70 | } 71 | KeyCode::Backspace => { 72 | app.mailing_list_selection.remove_last_target_list_char(); 73 | } 74 | KeyCode::Esc => { 75 | return Ok(ControlFlow::Break(())); 76 | } 77 | KeyCode::Char(ch) => { 78 | app.mailing_list_selection.push_char_to_target_list(ch); 79 | } 80 | KeyCode::Down => { 81 | app.mailing_list_selection.highlight_below_list(); 82 | } 83 | KeyCode::Up => { 84 | app.mailing_list_selection.highlight_above_list(); 85 | } 86 | _ => {} 87 | } 88 | Ok(ControlFlow::Continue(terminal)) 89 | } 90 | 91 | // TODO: Move this to a more appropriate place 92 | pub fn generate_help_popup() -> Box { 93 | let popup = HelpPopUpBuilder::new() 94 | .title("Mailing List Selection") 95 | .description("This is the mailing list selection screen.\nYou can select a mailing list by typing the name of the list.") 96 | .keybind("ESC", "Exit") 97 | .keybind("ENTER", "Open the selected mailing list") 98 | .keybind("?", "Show this help screen") 99 | .keybind("🡇", "Down") 100 | .keybind("🡅", "Up") 101 | .keybind("F1", "Show bookmarked patchsets") 102 | .keybind("F2", "Edit config options") 103 | .keybind("F5", "Refresh lists") 104 | .build(); 105 | 106 | Box::new(popup) 107 | } 108 | -------------------------------------------------------------------------------- /proc_macros/tests/serde_individual_default.rs: -------------------------------------------------------------------------------- 1 | use proc_macros::serde_individual_default; 2 | 3 | use derive_getters::Getters; 4 | use serde::Serialize; 5 | 6 | #[derive(Serialize, Getters)] 7 | #[serde_individual_default] 8 | struct Example { 9 | #[getter(skip)] 10 | test_1: i64, 11 | test_2: i64, 12 | test_3: String, 13 | } 14 | 15 | impl Default for Example { 16 | fn default() -> Self { 17 | Example { 18 | test_1: 3942, 19 | test_2: 42390, 20 | test_3: "a".to_string(), 21 | } 22 | } 23 | } 24 | 25 | #[serde_individual_default] 26 | struct ExampleWithoutSerialize { 27 | test_1: i64, 28 | test_2: i64, 29 | } 30 | 31 | impl Default for ExampleWithoutSerialize { 32 | fn default() -> Self { 33 | ExampleWithoutSerialize { 34 | test_1: 765, 35 | test_2: 126, 36 | } 37 | } 38 | } 39 | 40 | #[serde_individual_default] 41 | pub struct ExamplePublic { 42 | test_1: i64, 43 | test_2: i64, 44 | } 45 | 46 | impl Default for ExamplePublic { 47 | fn default() -> Self { 48 | ExamplePublic { 49 | test_1: 598, 50 | test_2: 403, 51 | } 52 | } 53 | } 54 | 55 | #[test] 56 | fn should_have_default_serialization() { 57 | // Case 1: test_3 missing 58 | 59 | // Example JSON string that doesn't contain `test_3` but has customized `test_1` and `test_2` 60 | let json_data_1 = serde_json::json!({ 61 | "test_1": 500, 62 | "test_2": 100 63 | }); 64 | 65 | let example_struct_1: Example = serde_json::from_value(json_data_1).unwrap(); 66 | 67 | // Assert that`test_1` and `test_2` are set to the custom value 68 | assert_eq!(example_struct_1.test_1, 500); 69 | assert_eq!(example_struct_1.test_2, 100); 70 | 71 | // Assert that `test_3` is set to the default value (a) 72 | assert_eq!(example_struct_1.test_3, "a".to_string()); 73 | 74 | // Case 2: test_2 missing 75 | 76 | // Example JSON string that doesn't contain `test_2` but has customized `test_1` and `test_3` 77 | let json_data_2 = serde_json::json!({ 78 | "test_1": 999, 79 | "test_3": "test".to_string() 80 | }); 81 | 82 | let example_struct_2: Example = serde_json::from_value(json_data_2).unwrap(); 83 | 84 | // Assert that`test_1` and `test_3` are set to the custom value 85 | assert_eq!(example_struct_2.test_1, 999); 86 | assert_eq!(example_struct_2.test_3, "test".to_string()); 87 | 88 | // Assert that `test_2` is set to the default value (42390) 89 | assert_eq!(example_struct_2.test_2, 42390); 90 | } 91 | 92 | #[test] 93 | fn should_preserve_other_attributes() { 94 | // Example JSON string that doesn't contain `test_3` but has customized `test_1` and `test_2` 95 | let json_data = serde_json::json!({ 96 | "test_1": 500, 97 | "test_2": 100, 98 | "test_3": "b".to_string() 99 | }); 100 | 101 | let example_struct: Example = serde_json::from_value(json_data).unwrap(); 102 | 103 | // Assert that`test_2` and `test_3` have getters 104 | assert_eq!(example_struct.test_1, 500); 105 | assert_eq!(example_struct.test_2(), 100); 106 | assert_eq!(example_struct.test_3(), &"b".to_string()); 107 | } 108 | 109 | #[test] 110 | fn test_struct_without_serialize() { 111 | let json_data = serde_json::json!({ 112 | "test_2": 123, 113 | }); 114 | 115 | let example_without_serialize: ExampleWithoutSerialize = 116 | serde_json::from_value(json_data).unwrap(); 117 | 118 | assert_eq!(example_without_serialize.test_1, 765); 119 | assert_eq!(example_without_serialize.test_2, 123); 120 | } 121 | 122 | #[test] 123 | fn test_public_struct() { 124 | let json_data = serde_json::json!({ 125 | "test_1": 345, 126 | }); 127 | 128 | let example_public: ExamplePublic = serde_json::from_value(json_data).unwrap(); 129 | 130 | assert_eq!(example_public.test_1, 345); 131 | assert_eq!(example_public.test_2, 403); 132 | } 133 | -------------------------------------------------------------------------------- /src/lore/lore_api_client.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use mockall::automock; 4 | use thiserror::Error; 5 | use ureq::tls::TlsConfig; 6 | use ureq::Agent; 7 | 8 | #[cfg(test)] 9 | mod tests; 10 | 11 | const LORE_DOMAIN: &str = r"https://lore.kernel.org"; 12 | const BASE_QUERY_FOR_FEED_REQUEST: &str = r"?x=A&q=((s:patch+OR+s:rfc)+AND+NOT+s:re:)"; 13 | 14 | #[derive(Error, Debug)] 15 | pub enum ClientError { 16 | #[error(transparent)] 17 | FromUreq(#[from] ureq::Error), 18 | 19 | #[error("Feed ended")] 20 | EndOfFeed, 21 | } 22 | 23 | #[derive(Clone)] 24 | pub struct BlockingLoreAPIClient { 25 | pub lore_domain: String, 26 | client: ureq::Agent, 27 | } 28 | impl Default for BlockingLoreAPIClient { 29 | fn default() -> Self { 30 | let kw_agent: String = format!("kworkflow/patch-hub/{}", env!("CARGO_PKG_VERSION")); 31 | 32 | let agent: Agent = Agent::config_builder() 33 | .user_agent(ureq::config::AutoHeaderValue::from(kw_agent)) 34 | .timeout_per_call(Some(Duration::from_secs(120))) 35 | .tls_config(TlsConfig::builder().build()) 36 | .build() 37 | .into(); 38 | Self::new(agent) 39 | } 40 | } 41 | 42 | impl BlockingLoreAPIClient { 43 | pub fn new(client: ureq::Agent) -> BlockingLoreAPIClient { 44 | BlockingLoreAPIClient { 45 | lore_domain: LORE_DOMAIN.to_string(), 46 | client, 47 | } 48 | } 49 | } 50 | 51 | #[automock] 52 | pub trait PatchFeedRequest { 53 | fn request_patch_feed( 54 | &self, 55 | target_list: &str, 56 | min_index: usize, 57 | ) -> Result; 58 | } 59 | 60 | impl PatchFeedRequest for BlockingLoreAPIClient { 61 | fn request_patch_feed( 62 | &self, 63 | target_list: &str, 64 | min_index: usize, 65 | ) -> Result { 66 | let feed_url: String = format!( 67 | "{}/{target_list}/{BASE_QUERY_FOR_FEED_REQUEST}&o={min_index}", 68 | self.lore_domain 69 | ); 70 | 71 | let request_builder = self 72 | .client 73 | .get(feed_url) 74 | .header("Accept", "text/html,application/xhtml+xml,application/xml"); 75 | 76 | let feed_response_body = request_builder.call()?.body_mut().read_to_string()?; 77 | 78 | if feed_response_body.eq(r"") { 79 | return Err(ClientError::EndOfFeed); 80 | }; 81 | 82 | Ok(feed_response_body) 83 | } 84 | } 85 | 86 | #[automock] 87 | pub trait AvailableListsRequest { 88 | fn request_available_lists(&self, min_index: usize) -> Result; 89 | } 90 | 91 | impl AvailableListsRequest for BlockingLoreAPIClient { 92 | fn request_available_lists(&self, min_index: usize) -> Result { 93 | let available_lists_url = format!("{}/?&o={min_index}", self.lore_domain); 94 | 95 | let body: String = ureq::get(&available_lists_url) 96 | .header("Accept", "text/html,application/xhtml+xml,application/xml") 97 | .call()? 98 | .body_mut() 99 | .read_to_string()?; 100 | Ok(body) 101 | } 102 | } 103 | 104 | #[automock] 105 | pub trait PatchHTMLRequest { 106 | fn request_patch_html( 107 | &self, 108 | target_list: &str, 109 | message_id: &str, 110 | ) -> Result; 111 | } 112 | 113 | impl PatchHTMLRequest for BlockingLoreAPIClient { 114 | fn request_patch_html( 115 | &self, 116 | target_list: &str, 117 | message_id: &str, 118 | ) -> Result { 119 | let patch_html_url = format!("{}/{target_list}/{message_id}/", self.lore_domain); 120 | 121 | let body: String = ureq::get(&patch_html_url) 122 | .header("Accept", "text/html,application/xhtml+xml,application/xml") 123 | .call()? 124 | .body_mut() 125 | .read_to_string()?; 126 | 127 | Ok(body) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/lore/patch/tests.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use serde_xml_rs::from_str; 3 | 4 | #[test] 5 | fn can_deserialize_patch_without_in_reply_to() { 6 | let expected_patch: Patch = Patch::new( 7 | "[PATCH 0/42] hitchhiker/guide: Complete Collection".to_string(), 8 | Author { 9 | name: "Foo Bar".to_string(), 10 | email: "foo@bar.foo.bar".to_string(), 11 | }, 12 | MessageID { 13 | href: "http://lore.kernel.org/some-list/1234-1-foo@bar.foo.bar".to_string(), 14 | }, 15 | None, 16 | "2024-07-06T19:15:48Z".to_string(), 17 | ); 18 | let serialized_patch: &str = r#" 19 | 20 | 21 | Foo Bar 22 | foo@bar.foo.bar 23 | 24 | [PATCH 0/42] hitchhiker/guide: Complete Collection 25 | 2024-07-06T19:15:48Z 26 | 28 | urn:uuid:123-abcd-1f2a3b 29 | 30 | 31 | "#; 32 | 33 | let actual_patch: Patch = from_str(serialized_patch).unwrap(); 34 | 35 | assert_eq!( 36 | expected_patch, actual_patch, 37 | "An entry from a patch feed should deserialize into" 38 | ) 39 | } 40 | 41 | #[test] 42 | fn can_deserialize_patch_with_in_reply_to() { 43 | let expected_patch: Patch = Patch::new( 44 | "[PATCH 3/42] hitchhiker/guide: Life, the Universe and Everything".to_string(), 45 | Author { 46 | name: "Foo Bar".to_string(), 47 | email: "foo@bar.foo.bar".to_string(), 48 | }, 49 | MessageID { 50 | href: "http://lore.kernel.org/some-list/1234-2-foo@bar.foo.bar".to_string(), 51 | }, 52 | Some(MessageID { 53 | href: "http://lore.kernel.org/some-list/1234-1-foo@bar.foo.bar".to_string(), 54 | }), 55 | "2024-07-06T19:16:53Z".to_string(), 56 | ); 57 | let serialized_patch: &str = r#" 58 | 59 | 60 | Foo Bar 61 | foo@bar.foo.bar 62 | 63 | [PATCH 3/42] hitchhiker/guide: Life, the Universe and Everything 64 | 2024-07-06T19:16:53Z 65 | 67 | urn:uuid:123-abcd-1f2a3b 68 | 71 | 72 | 73 | "#; 74 | 75 | let actual_patch: Patch = from_str(serialized_patch).unwrap(); 76 | 77 | assert_eq!( 78 | expected_patch, actual_patch, 79 | "An entry from a patch feed should deserialize into" 80 | ) 81 | } 82 | 83 | #[test] 84 | fn test_update_patch_metadata() { 85 | let patch_regex: PatchRegex = PatchRegex::new(); 86 | let mut patch: Patch = Patch::new( 87 | "[RESEND][v7 PATCH 3/42] hitchhiker/guide: Life, the Universe and Everything".to_string(), 88 | Author { 89 | name: "Foo Bar".to_string(), 90 | email: "foo@bar.foo.bar".to_string(), 91 | }, 92 | MessageID { 93 | href: "http://lore.kernel.org/some-list/1234-2-foo@bar.foo.bar".to_string(), 94 | }, 95 | Some(MessageID { 96 | href: "http://lore.kernel.org/some-list/1234-1-foo@bar.foo.bar".to_string(), 97 | }), 98 | "2024-07-06T19:16:53Z".to_string(), 99 | ); 100 | 101 | patch.update_patch_metadata(&patch_regex); 102 | 103 | assert_eq!( 104 | "[RESEND] hitchhiker/guide: Life, the Universe and Everything", 105 | patch.title(), 106 | "The title should have the patch tag `[v7 PATCH 3/42]` stripped" 107 | ); 108 | assert_eq!(7, patch.version(), "Wrong version!"); 109 | assert_eq!(3, patch.number_in_series(), "Wrong number in series!"); 110 | assert_eq!(42, patch.total_in_series(), "Wrong total in series!"); 111 | } 112 | -------------------------------------------------------------------------------- /src/ui/loading_screen.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use ratatui::{ 4 | prelude::Backend, 5 | style::{Color, Style}, 6 | text::{Line, Span}, 7 | widgets::{Block, Borders, Paragraph, Wrap}, 8 | Frame, Terminal, 9 | }; 10 | 11 | use super::centered_rect; 12 | 13 | const SPINNER: [char; 8] = [ 14 | '\u{1F311}', 15 | '\u{1F312}', 16 | '\u{1F313}', 17 | '\u{1F314}', 18 | '\u{1F315}', 19 | '\u{1F316}', 20 | '\u{1F317}', 21 | '\u{1F318}', 22 | ]; 23 | static mut SPINNER_TICK: usize = 1; 24 | 25 | const LOADING_AREA_EXTRA_FACTOR_WIDTH: f32 = 1.3; 26 | const LOADING_AREA_EXTRA_LINES: u16 = 2; 27 | 28 | /// This function renders a loading screen taking a `terminal` instance and a 29 | /// `title`. 30 | pub fn render(mut terminal: Terminal, title: impl Display) -> Terminal { 31 | let _ = terminal.draw(|f| draw_loading_screen(f, title)); 32 | terminal 33 | } 34 | 35 | /// Gets the current spinner state and updates the tick. 36 | fn spinner() -> char { 37 | let char_to_ret = SPINNER[unsafe { SPINNER_TICK }]; 38 | unsafe { 39 | SPINNER_TICK = (SPINNER_TICK + 1) % 8; 40 | } 41 | char_to_ret 42 | } 43 | 44 | /// The actual implementation of the loading screen rendering. Currently the 45 | /// loading notification is static. 46 | fn draw_loading_screen(f: &mut Frame, title: impl Display) { 47 | let frame_area = f.area(); 48 | let loading_text = format!("{} {}", title, spinner()); 49 | 50 | let (width_pct, height_pct) = 51 | calculate_loading_percentages(loading_text.len(), frame_area.width, frame_area.height); 52 | 53 | let loading_area = centered_rect(width_pct, height_pct, frame_area); 54 | 55 | let loading_par = Paragraph::new(Line::from(Span::styled( 56 | loading_text, 57 | Style::default().fg(Color::Green), 58 | ))) 59 | .block(Block::default().borders(Borders::ALL)) 60 | .centered() 61 | .wrap(Wrap { trim: true }); 62 | 63 | f.render_widget(loading_par, loading_area); 64 | } 65 | 66 | /// # Tests 67 | /// 68 | /// [tests::test_calculate_loading_percentages] 69 | fn calculate_loading_percentages( 70 | loading_text_len: usize, 71 | frame_area_width: u16, 72 | frame_area_height: u16, 73 | ) -> (u16, u16) { 74 | let min_width = (loading_text_len as f32 * LOADING_AREA_EXTRA_FACTOR_WIDTH).ceil() as u16; 75 | let min_height = { 76 | let lines = (loading_text_len as u16).div_ceil(frame_area_width).max(1); 77 | lines + LOADING_AREA_EXTRA_LINES 78 | }; 79 | 80 | let width_pct = (100 * min_width).div_ceil(frame_area_width).min(100); 81 | let height_pct = (100 * min_height).div_ceil(frame_area_height).min(100); 82 | 83 | (width_pct, height_pct) 84 | } 85 | 86 | #[cfg(test)] 87 | mod tests { 88 | use super::*; 89 | 90 | #[test] 91 | /// Tests [calculate_loading_percentages] 92 | fn test_calculate_loading_percentages() { 93 | // Test case 1: standard case with reasonable frame size 94 | let (width_pct, height_pct) = calculate_loading_percentages(10, 40, 20); 95 | assert_eq!(width_pct, 33); // (10 * 1.3 = 13 / 40 * 100) = 32.5, rounded up to 33 96 | assert_eq!(height_pct, 15); // min_height: 1 line + 2 extra = 3, (3 / 20 * 100) = 15 97 | 98 | // Test case 2: text len exceeds frame width, forcing width_pct to cap at 100% 99 | let (width_pct, height_pct) = calculate_loading_percentages(80, 40, 20); 100 | assert_eq!(width_pct, 100); // min_width: 104 exceeds frame width, capped at 100% 101 | assert_eq!(height_pct, 20); // min_height: 2 lines + 2 extra = 4, (4 / 20 * 100) = 20 102 | 103 | // Test case 3: Very small loading text within large frame area 104 | let (width_pct, height_pct) = calculate_loading_percentages(5, 50, 30); 105 | assert_eq!(width_pct, 14); // (5 * 1.3 = 7 / 50 * 100) = 14 106 | assert_eq!(height_pct, 10); // min_height: 1 line + 2 extra = 3, (3 / 30 * 100) = 10 107 | 108 | // Test case 4: small frame height, causing height_pct to cap at 100% 109 | let (width_pct, height_pct) = calculate_loading_percentages(100, 40, 4); 110 | assert_eq!(width_pct, 100); // min_width: 130 exceeds frame width, capped at 100% 111 | assert_eq!(height_pct, 100); // min_height: 3 lines + 2 extra = 5, capped at 100% 112 | 113 | // Test case 5: small frame width, but big height 114 | let (width_pct, height_pct) = calculate_loading_percentages(100, 10, 100); 115 | assert_eq!(width_pct, 100); // min_width: 130 exceeds frame width, capped at 100% 116 | assert_eq!(height_pct, 12); // min_height: 10 lines + 2 extra = 12 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /proc_macros/src/lib.rs: -------------------------------------------------------------------------------- 1 | extern crate proc_macro; 2 | use proc_macro::TokenStream; 3 | use quote::{format_ident, quote}; 4 | use syn::{parse_macro_input, Data, DeriveInput}; 5 | 6 | /// This procedural macro create default deserealization functions for each 7 | /// structure attribute based on std::Default impl. 8 | /// 9 | /// It applies default values only for fields missing from the deserialized input. 10 | /// 11 | /// # Example 12 | /// 13 | /// ```rust 14 | /// use serde::Deserialize; 15 | /// use your_proc_macro_crate::serde_individual_default; 16 | /// 17 | /// #[derive(Deserialize, Getters)] 18 | /// #[serde_individual_default] 19 | /// struct Example { 20 | /// #[getter(skip)] 21 | /// test_1: i64, 22 | /// test_2: i64, 23 | /// test_3: String, 24 | /// } 25 | /// impl Default for Example { 26 | /// fn default() -> Self { 27 | /// Example { 28 | /// test_1: 3942, 29 | /// test_2: 42390, 30 | /// test_3: "a".to_string(), 31 | /// } 32 | /// } 33 | /// } 34 | /// 35 | /// let json_data_1 = serde_json::json!({ 36 | /// "test_1": 500, 37 | /// "test_2": 100 38 | /// }); 39 | /// let example_struct_1: Example = serde_json::from_value(json_data_1).unwrap(); 40 | /// assert_eq!(example_struct_1.test_1, 500); 41 | /// assert_eq!(example_struct_1.test_2, 100); 42 | /// assert_eq!(example_struct_1.test_3, "a".to_string()); 43 | /// ``` 44 | #[proc_macro_attribute] 45 | pub fn serde_individual_default(_attr: TokenStream, input: TokenStream) -> TokenStream { 46 | let input = parse_macro_input!(input as DeriveInput); 47 | let struct_name = &input.ident; 48 | let struct_generics = &input.generics; 49 | let struct_fields = match &input.data { 50 | Data::Struct(s) => &s.fields, 51 | _ => panic!("SerdeIndividualDefault can only be used with structs"), 52 | }; 53 | let struct_attrs = &input.attrs; 54 | let struct_visibility = &input.vis; 55 | 56 | let struct_name_str = struct_name.to_string(); 57 | let (struct_impl_generics, struct_ty_generics, struct_where_clause) = 58 | struct_generics.split_for_impl(); 59 | 60 | // store only one struct::default object in memory 61 | let default_config_struct_name = 62 | format_ident!("DEFAULT_{}_STATIC", struct_name_str.to_ascii_uppercase()); 63 | let struct_default_lazy_construction_definition = { 64 | quote! { 65 | lazy_static::lazy_static! { 66 | static ref #default_config_struct_name: #struct_name = #struct_name::default(); 67 | } 68 | } 69 | }; 70 | 71 | // build struct attributes with #[serde(default = "")] and build the default function itself 72 | let (all_field_attrs, default_deserialize_function_definitions) = struct_fields.iter().fold( 73 | (vec![], vec![]), 74 | |(mut all_field_attrs, mut default_deserialize_function_definitions), field| { 75 | let field_name = &field.ident; 76 | let field_type = &field.ty; 77 | let field_vis = &field.vis; 78 | let field_attrs = &field.attrs; 79 | let field_name_str = field_name.as_ref().unwrap().to_string(); 80 | 81 | // default function name will be named default_{struct_name}_{field_name} 82 | let default_deserialize_function_name = 83 | format_ident!("default_{}_{}", struct_name_str, field_name_str); 84 | 85 | let default_deserialize_function_name_str = 86 | default_deserialize_function_name.to_string(); 87 | 88 | all_field_attrs.push(quote! { 89 | #(#field_attrs)* 90 | #[serde(default = #default_deserialize_function_name_str)] 91 | #field_vis #field_name: #field_type, 92 | }); 93 | default_deserialize_function_definitions.push(quote! { 94 | fn #default_deserialize_function_name() -> #field_type { 95 | #default_config_struct_name.#field_name.clone() 96 | } 97 | }); 98 | 99 | (all_field_attrs, default_deserialize_function_definitions) 100 | }, 101 | ); 102 | 103 | // build final struct. 104 | //We have to explicitly derive Deserialize here so the serde attribute works 105 | let expanded_token_stream = quote! { 106 | #[derive(serde::Deserialize)] 107 | #(#struct_attrs)* 108 | #struct_visibility struct #struct_name #struct_impl_generics { 109 | #(#all_field_attrs)* 110 | } #struct_ty_generics #struct_where_clause 111 | 112 | #struct_default_lazy_construction_definition 113 | 114 | #(#default_deserialize_function_definitions)* 115 | }; 116 | TokenStream::from(expanded_token_stream) 117 | } 118 | -------------------------------------------------------------------------------- /src/lore/patch.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | 3 | use derive_getters::Getters; 4 | use regex::Regex; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | #[cfg(test)] 8 | mod tests; 9 | 10 | #[derive(Getters, Serialize, Deserialize, Debug, Clone)] 11 | pub struct PatchFeed { 12 | #[serde(rename = "entry")] 13 | patches: Vec, 14 | } 15 | 16 | #[derive(Getters, Serialize, Deserialize, Debug, Clone, PartialEq)] 17 | pub struct Patch { 18 | r#title: String, 19 | #[serde(default = "default_version")] 20 | #[getter(skip)] 21 | version: usize, 22 | #[serde(default = "default_number_in_series")] 23 | #[getter(skip)] 24 | number_in_series: usize, 25 | #[serde(default = "default_total_in_series")] 26 | #[getter(skip)] 27 | total_in_series: usize, 28 | author: Author, 29 | #[serde(rename = "link")] 30 | message_id: MessageID, 31 | #[serde(rename = "in-reply-to")] 32 | in_reply_to: Option, 33 | updated: String, 34 | } 35 | 36 | #[derive(Serialize, Deserialize, Debug, Clone, Hash, Eq, PartialEq)] 37 | pub struct Author { 38 | pub name: String, 39 | pub email: String, 40 | } 41 | 42 | impl Display for Author { 43 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 44 | write!(f, "{} <{}>", self.name, self.email)?; 45 | Ok(()) 46 | } 47 | } 48 | 49 | #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] 50 | pub struct MessageID { 51 | pub href: String, 52 | } 53 | 54 | fn default_version() -> usize { 55 | 1 56 | } 57 | fn default_number_in_series() -> usize { 58 | 1 59 | } 60 | fn default_total_in_series() -> usize { 61 | 1 62 | } 63 | 64 | impl Patch { 65 | pub fn new( 66 | title: String, 67 | author: Author, 68 | message_id: MessageID, 69 | in_reply_to: Option, 70 | updated: String, 71 | ) -> Patch { 72 | Patch { 73 | title, 74 | author, 75 | version: 1, 76 | number_in_series: 1, 77 | total_in_series: 1, 78 | message_id, 79 | in_reply_to, 80 | updated, 81 | } 82 | } 83 | 84 | pub fn version(&self) -> usize { 85 | self.version 86 | } 87 | 88 | pub fn number_in_series(&self) -> usize { 89 | self.number_in_series 90 | } 91 | 92 | pub fn total_in_series(&self) -> usize { 93 | self.total_in_series 94 | } 95 | 96 | pub fn update_patch_metadata(&mut self, patch_regex: &PatchRegex) { 97 | let patch_tag: String = match self.get_patch_tag(&patch_regex.re_patch_tag) { 98 | Some(value) => value.to_string(), 99 | None => return, 100 | }; 101 | 102 | self.remove_patch_tag_from_title(&patch_tag); 103 | self.set_version(&patch_tag, &patch_regex.re_patch_version); 104 | self.set_number_in_series(&patch_tag, &patch_regex.re_patch_series); 105 | self.set_total_in_series(&patch_tag, &patch_regex.re_patch_series); 106 | } 107 | 108 | fn get_patch_tag(&self, re_patch_tag: &Regex) -> Option<&str> { 109 | match re_patch_tag.find(&self.title) { 110 | Some(patch_tag) => Some(patch_tag.as_str()), 111 | None => None, 112 | } 113 | } 114 | 115 | fn remove_patch_tag_from_title(&mut self, patch_tag: &str) { 116 | self.title = self.title.replace(patch_tag, "").trim().to_string(); 117 | } 118 | 119 | fn set_version(&mut self, patch_tag: &str, re_patch_version: &Regex) { 120 | if let Some(capture) = re_patch_version.captures(patch_tag) { 121 | if let Some(version) = capture.get(1) { 122 | self.version = version.as_str().parse().unwrap(); 123 | } 124 | } 125 | } 126 | 127 | fn set_number_in_series(&mut self, patch_tag: &str, re_patch_series: &Regex) { 128 | if let Some(capture) = re_patch_series.captures(patch_tag) { 129 | if let Some(number_in_series) = capture.get(1) { 130 | self.number_in_series = number_in_series.as_str().parse().unwrap(); 131 | } 132 | } 133 | } 134 | 135 | fn set_total_in_series(&mut self, patch_tag: &str, re_patch_series: &Regex) { 136 | if let Some(capture) = re_patch_series.captures(patch_tag) { 137 | if let Some(total_in_series) = capture.get(2) { 138 | self.total_in_series = total_in_series.as_str().parse().unwrap(); 139 | } 140 | } 141 | } 142 | } 143 | 144 | pub struct PatchRegex { 145 | pub re_patch_tag: Regex, 146 | pub re_patch_version: Regex, 147 | pub re_patch_series: Regex, 148 | } 149 | 150 | impl Default for PatchRegex { 151 | fn default() -> Self { 152 | Self::new() 153 | } 154 | } 155 | 156 | impl PatchRegex { 157 | pub fn new() -> PatchRegex { 158 | let re_patch_tag = Regex::new(r"(?i)\[[^\]]*(PATCH|RFC)[^\[]*\]").unwrap(); 159 | let re_patch_version = Regex::new(r"[v|V] *(\d+)").unwrap(); 160 | let re_patch_series = Regex::new(r"(\d+) */ *(\d+)").unwrap(); 161 | 162 | PatchRegex { 163 | re_patch_tag, 164 | re_patch_version, 165 | re_patch_series, 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/handler.rs: -------------------------------------------------------------------------------- 1 | pub mod bookmarked; 2 | pub mod details_actions; 3 | pub mod edit_config; 4 | pub mod latest; 5 | pub mod mail_list; 6 | 7 | use std::{ 8 | ops::ControlFlow, 9 | time::{Duration, Instant}, 10 | }; 11 | 12 | use crate::{ 13 | app::{logging::Logger, screens::CurrentScreen, App}, 14 | loading_screen, 15 | ui::draw_ui, 16 | }; 17 | 18 | use bookmarked::handle_bookmarked_patchsets; 19 | use color_eyre::eyre::bail; 20 | use details_actions::handle_patchset_details; 21 | use edit_config::handle_edit_config; 22 | use latest::handle_latest_patchsets; 23 | use mail_list::handle_mailing_list_selection; 24 | use ratatui::{ 25 | crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}, 26 | prelude::Backend, 27 | Terminal, 28 | }; 29 | 30 | fn key_handling( 31 | mut terminal: Terminal, 32 | app: &mut App, 33 | key: KeyEvent, 34 | ) -> color_eyre::Result>> 35 | where 36 | B: Backend + Send + 'static, 37 | { 38 | if let Some(popup) = app.popup.as_mut() { 39 | if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) { 40 | app.popup = None; 41 | } else { 42 | popup.handle(key)?; 43 | } 44 | } else { 45 | match app.current_screen { 46 | CurrentScreen::MailingListSelection => { 47 | return handle_mailing_list_selection(app, key, terminal); 48 | } 49 | CurrentScreen::BookmarkedPatchsets => { 50 | return handle_bookmarked_patchsets(app, key, terminal); 51 | } 52 | CurrentScreen::PatchsetDetails => { 53 | handle_patchset_details(app, key, &mut terminal)?; 54 | } 55 | CurrentScreen::EditConfig => { 56 | handle_edit_config(app, key)?; 57 | } 58 | CurrentScreen::LatestPatchsets => { 59 | return handle_latest_patchsets(app, key, terminal); 60 | } 61 | } 62 | } 63 | Ok(ControlFlow::Continue(terminal)) 64 | } 65 | 66 | fn logic_handling(mut terminal: Terminal, app: &mut App) -> color_eyre::Result> 67 | where 68 | B: Backend + Send + 'static, 69 | { 70 | match app.current_screen { 71 | CurrentScreen::MailingListSelection => { 72 | if app.mailing_list_selection.mailing_lists.is_empty() { 73 | terminal = loading_screen! { 74 | terminal, "Fetching mailing lists" => { 75 | app.mailing_list_selection.refresh_available_mailing_lists() 76 | } 77 | }; 78 | } 79 | } 80 | CurrentScreen::LatestPatchsets => { 81 | let patchsets_state = app.latest_patchsets.as_mut().unwrap(); 82 | let target_list = patchsets_state.target_list().to_string(); 83 | if patchsets_state.processed_patchsets_count() == 0 { 84 | terminal = loading_screen! { 85 | terminal, 86 | format!("Fetching patchsets from {}", target_list) => { 87 | patchsets_state.fetch_current_page() 88 | } 89 | }; 90 | 91 | app.mailing_list_selection.clear_target_list(); 92 | } 93 | } 94 | CurrentScreen::BookmarkedPatchsets => { 95 | if app.bookmarked_patchsets.bookmarked_patchsets.is_empty() { 96 | app.set_current_screen(CurrentScreen::MailingListSelection); 97 | } 98 | } 99 | _ => {} 100 | } 101 | 102 | Ok(terminal) 103 | } 104 | 105 | pub fn run_app(mut terminal: Terminal, mut app: App) -> color_eyre::Result<()> 106 | where 107 | B: Backend + Send + 'static, 108 | { 109 | if !app.check_external_deps() { 110 | Logger::error("patch-hub cannot be executed because some dependencies are missing"); 111 | bail!("patch-hub cannot be executed because some dependencies are missing, check logs for more information"); 112 | } 113 | 114 | loop { 115 | terminal = logic_handling(terminal, &mut app)?; 116 | 117 | terminal.draw(|f| draw_ui(f, &app))?; 118 | 119 | // *IMPORTANT*: Uncommenting the if below makes `patch-hub` not block 120 | // until an event is captured. We should only do it when (if ever) we 121 | // need to refresh the UI independently of any event as doing so gravely 122 | // hinders the performance to below acceptable. 123 | // if event::poll(Duration::from_millis(16))? { 124 | if let Event::Key(key) = event::read()? { 125 | if key.kind == KeyEventKind::Release { 126 | continue; 127 | } 128 | match key_handling(terminal, &mut app, key)? { 129 | ControlFlow::Continue(t) => terminal = t, 130 | ControlFlow::Break(_) => return Ok(()), 131 | } 132 | } 133 | // } 134 | } 135 | } 136 | 137 | fn wait_key_press(ch: char, wait_time: Duration) -> color_eyre::Result { 138 | let start = Instant::now(); 139 | 140 | while Instant::now() - start < wait_time { 141 | if event::poll(Duration::from_millis(16))? { 142 | if let Event::Key(key) = event::read()? { 143 | if key.kind == KeyEventKind::Release { 144 | continue; 145 | } 146 | if key.code == KeyCode::Char(ch) { 147 | return Ok(true); 148 | } 149 | } 150 | } 151 | } 152 | 153 | Ok(false) 154 | } 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `patch-hub` 2 | 3 | [![License: GPL 4 | v3+](https://img.shields.io/badge/license-GPL%20v2%2B-blue.svg)](https://www.gnu.org/licenses/old-licenses/gpl-2.0.html) 5 | 6 | ## :information_source: About 7 | 8 | `patch-hub` is a terminal-based user interface (TUI) designed to simplify 9 | working with software patches sent through mailing lists in Linux-related 10 | development. It provides an efficient way to navigate and interact with patches 11 | archived on [lore.kernel.org](https://lore.kernel.org), specifically for the 12 | Linux kernel and adjacent projects. 13 | 14 | `patch-hub` is a sub-project of `kw`, a Developer Automation Workflow System 15 | (DAWS) for Linux kernel developers. The goal of `kw` is to "reduce the setup 16 | overhead of working with the Linux kernel and provide tools to support 17 | developers in their daily tasks." 18 | 19 | While `patch-hub` can be used as a standalone tool, we highly recommend 20 | integrating it with the full `kw` suite for a more seamless developer 21 | experience. Check out the [kw 22 | repository](https://github.com/kworkflow/kworkflow/) to learn more. 23 | 24 | ## :star: Features 25 | 26 | patch-hub-demo-v0.1.0 28 | 29 | ### Core Features 30 | 31 | - **Mailing List Selection** — Dynamically fetch and browse mailing lists 32 | archived on [lore.kernel.org](https://lore.kernel.org). 33 | 34 | - **Latest Patchsets** — View the most recent patchsets from a selected mailing 35 | list in an organized flow. 36 | 37 | - **Patchset Details & Actions** — View individual patch contents and access 38 | metadata like title, author, version, number of patches, last update, and 39 | code-review trailers. Take quick actions like: 40 | - **Apply patch(set)** to your local kernel tree. 41 | - **Bookmark** important patches 42 | - **Reply with `Reviewed-by` tags** to the series. 43 | 44 | - **Bookmarking System** — Bookmark patchsets for easy reference 45 | later. 46 | 47 | - **Enhanced Patchset Rendering** — Use external tools such as 48 | [`bat`](https://github.com/sharkdp/bat), 49 | [`delta`](https://github.com/dandavison/delta), 50 | [`diff-so-fancy`](https://github.com/so-fancy/diff-so-fancy) for better 51 | visualization or use the built-in vanilla renderer (`default`) for a 52 | dependency-free experience. 53 | 54 | ### More Features Coming! 55 | 56 | Future updates will introduce deeper integration with kw, including: 57 | 58 | - Seamlessly compile and deploy patchset versions of the kernel to target 59 | machines. 60 | 61 | ## :package: How To Install 62 | 63 | Before using `patch-hub`, make sure you have the following packages installed: 64 | 65 | - [`b4`](https://github.com/mricon/b4) - Required for working with patches from 66 | mailing lists. 67 | - [`git-email`](https://git-scm.com/docs/git-send-email) - Provides the `git 68 | send-email` command for replying to patches. 69 | - **Optional (but recommended) patchset renderers for enhanced previews:** 70 | - [`bat`](https://github.com/sharkdp/bat) 71 | - [`delta`](https://github.com/dandavison/delta) 72 | - [`diff-so-fancy`](https://github.com/so-fancy/diff-so-fancy) 73 | 74 | ### `patch-hub` in the `kw` suite 75 | 76 | `patch-hub` is a part of `kw` so if you already use kw, you don’t need to 77 | install Patch Hub separately—simply update kw to the latest version: 78 | ```bash 79 | kw self-update 80 | ``` 81 | After updating `kw`, you can launch `patch-hub` using: 82 | ```bash 83 | kw patch-hub 84 | ``` 85 | If you’re not using `kw` yet, consider installing it for a 86 | more fluid experience with `patch-hub` and other useful tools included in `kw`. 87 | 88 | ### :inbox_tray: Pre-compiled binaries 89 | 90 | Download pre-compiled binaries from our [releases 91 | page](https://github.com/kworkflow/patch-hub/releases). 92 | 93 | We provide two versions: 94 | 95 | - `-x86_64-unknown-linux-gnu` – Dynamically linked, relies on the GNU C Library 96 | **(glibc)**, making it more compatible across Linux distributions. 97 | - `-x86_64-unknown-linux-musl`– Statically linked, built with **musl libc**, 98 | producing a more portable and self-contained binary. 99 | 100 | ### :wrench: Compiling from Source 101 | 102 | :pushpin: **Requirements:** Ensure **Rust** (`rustc`) and **Cargo** are 103 | installed on your system. 104 | 105 | To build `patch-hub` from source: 106 | 107 | :one: Clone the repository: ```bash git clone 108 | https://github.com/kworkflow/patch-hub.git && cd patch-hub ``` 109 | 110 | :two: Compile 111 | with Cargo: ```bash cargo build --release ``` 112 | 113 | :three: The compiled binary will 114 | be available at: 115 | - `target/release/patch-hub` 116 | - `target/debug/patch-hub` (default build, without the `--release` option) 117 | 118 | Additionally, you can install the binary to make it available anywhere on your 119 | system: ```bash cargo install --path . ``` 120 | 121 | ## :handshake: How To Contribute 122 | 123 | We appreciate all contributions, whether it’s fixing bugs, adding features, improving documentation, or suggesting ideas! If you're looking for something to work on, check out our [issues page](https://github.com/kworkflow/patch-hub/issues). 124 | 125 | Since patch-hub is a sub-project of kw, some development workflows carry over — such as a similar branching strategy (master and unstable) — but keep in mind that patch-hub is written in Rust, while kw is Bash. For detailed guidelines on setting up your environment, coding standards, and submitting Pull Requests, please refer to our [CONTRIBUTING.md](./CONTRIBUTING.md). 126 | 127 | ### :pushpin: Quick Contribution Guide 128 | - **Report Issues:** Found a bug or have a feature request? Open an issue [here](https://github.com/kworkflow/patch-hub/issues). 129 | - **Submit a PR:** Fix an issue, improve code, or add a new feature—just follow the steps in [CONTRIBUTING.md](./CONTRIBUTING.md). 130 | - **Improve Documentation:** Enhancements to the README, guides, and comments are always welcome! 131 | -------------------------------------------------------------------------------- /src/ui/popup/review_trailers.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use patch_hub::lore::patch::Author; 4 | use ratatui::{ 5 | crossterm::event::KeyCode, 6 | layout::Alignment, 7 | style::{Color, Modifier, Style, Stylize}, 8 | text::Line, 9 | widgets::{Clear, Paragraph}, 10 | }; 11 | 12 | use crate::app::screens::details_actions::DetailsActions; 13 | 14 | use super::PopUp; 15 | 16 | #[derive(Debug)] 17 | pub struct ReviewTrailersPopUp { 18 | reviewed_by_text: String, 19 | tested_by_text: String, 20 | acked_by_text: String, 21 | offset: (u16, u16), 22 | max_offset: (u16, u16), 23 | dimensions: (u16, u16), 24 | } 25 | 26 | impl ReviewTrailersPopUp { 27 | /// Generate a pop-up that contains details about the code-review trailers 28 | /// of a specific patch. 29 | /// 30 | /// The specific patch is defined by the currently previewing patch of 31 | /// `@details_actions` and the information about the trailers is stored in 32 | /// the fields `reviewed_by`, `tested_by`, and `acked_by`, which are the 33 | /// tags considered for the generated pop-up. This function succeeds regardless if 34 | /// there are no code-review trailers for the specific patch. 35 | pub fn generate_trailers_popup(details_actions: &DetailsActions) -> Box { 36 | let i = details_actions.preview_index; 37 | let mut reviewed_by_text = String::new(); 38 | let mut tested_by_text = String::new(); 39 | let mut acked_by_text = String::new(); 40 | let mut columns = 0; 41 | 42 | // Auxiliary routines to avoid code duplications 43 | let mut update_columns = |line_len: usize| { 44 | if line_len > columns { 45 | columns = line_len; 46 | } 47 | }; 48 | let mut fill_text = |text: &mut String, authors: &HashSet| { 49 | for author in authors { 50 | let author = format!(" - {}\n", author); 51 | text.push_str(&author); 52 | update_columns(author.len()); 53 | } 54 | }; 55 | 56 | fill_text(&mut reviewed_by_text, &details_actions.reviewed_by[i]); 57 | fill_text(&mut tested_by_text, &details_actions.tested_by[i]); 58 | fill_text(&mut acked_by_text, &details_actions.acked_by[i]); 59 | 60 | let lines = (3 61 | + details_actions.reviewed_by[i].len() 62 | + details_actions.tested_by[i].len() 63 | + details_actions.acked_by[i].len()) as u16; 64 | 65 | // TODO: Calculate percentage based on the lines and screen size 66 | let dimensions = (50, 40); 67 | 68 | Box::new(ReviewTrailersPopUp { 69 | reviewed_by_text, 70 | tested_by_text, 71 | acked_by_text, 72 | offset: (0, 0), 73 | max_offset: (lines, columns as u16), 74 | dimensions, 75 | }) 76 | } 77 | } 78 | 79 | impl PopUp for ReviewTrailersPopUp { 80 | fn dimensions(&self) -> (u16, u16) { 81 | self.dimensions 82 | } 83 | 84 | /// Renders a centered overlaying pop-up with entries for code-review 85 | /// trailers of "Reviewed-by", "Tested-by", and "Acked-by". 86 | fn render(&self, f: &mut ratatui::Frame, chunk: ratatui::prelude::Rect) { 87 | let mut contents = vec![]; 88 | 89 | // Auxilliary closure to avoid code duplication 90 | let mut add_entry_to_contents = |name: &str, text: &str| { 91 | contents.push(Line::styled( 92 | name.to_string(), 93 | Style::default() 94 | .fg(Color::Cyan) 95 | .add_modifier(Modifier::BOLD) 96 | .add_modifier(Modifier::UNDERLINED), 97 | )); 98 | for line in text.lines() { 99 | contents.push(Line::styled( 100 | line.to_string(), 101 | Style::default().fg(Color::White), 102 | )); 103 | } 104 | contents.push(Line::from("")); // equivalent to newline 105 | }; 106 | 107 | add_entry_to_contents("Reviewed-by", &self.reviewed_by_text); 108 | add_entry_to_contents("Tested-by", &self.tested_by_text); 109 | add_entry_to_contents("Acked-by", &self.acked_by_text); 110 | 111 | let block = ratatui::widgets::Block::default() 112 | .title("Code-Review Trailers") 113 | .title_alignment(Alignment::Center) 114 | .title_style(ratatui::style::Style::default().bold().blue()) 115 | .title_bottom(Line::styled( 116 | "(ESC / q) Close", 117 | Style::default().bold().blue(), 118 | )) 119 | .borders(ratatui::widgets::Borders::ALL) 120 | .border_type(ratatui::widgets::BorderType::Double) 121 | .style(ratatui::style::Style::default()); 122 | 123 | let pop_up = Paragraph::new(contents) 124 | .style(ratatui::style::Style::default()) 125 | .block(block) 126 | .alignment(ratatui::layout::Alignment::Left) 127 | .scroll(self.offset); 128 | 129 | f.render_widget(Clear, chunk); 130 | f.render_widget(pop_up, chunk); 131 | } 132 | 133 | /// Handles simple one-char width navigation. 134 | fn handle(&mut self, key: ratatui::crossterm::event::KeyEvent) -> color_eyre::Result<()> { 135 | match key.code { 136 | KeyCode::Up | KeyCode::Char('k') => { 137 | if self.offset.0 > 0 { 138 | self.offset.0 -= 1; 139 | } 140 | } 141 | KeyCode::Down | KeyCode::Char('j') => { 142 | if self.offset.0 < self.max_offset.0 { 143 | self.offset.0 += 1; 144 | } 145 | } 146 | KeyCode::Left | KeyCode::Char('h') => { 147 | if self.offset.1 > 0 { 148 | self.offset.1 -= 1; 149 | } 150 | } 151 | KeyCode::Right | KeyCode::Char('l') => { 152 | if self.offset.1 < self.max_offset.1 { 153 | self.offset.1 += 1; 154 | } 155 | } 156 | _ => {} 157 | } 158 | 159 | Ok(()) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/handler/details_actions.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use crate::{ 4 | app::{screens::CurrentScreen, App}, 5 | ui::popup::{help::HelpPopUpBuilder, review_trailers::ReviewTrailersPopUp, PopUp}, 6 | utils, 7 | }; 8 | use ratatui::{ 9 | backend::Backend, 10 | crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, 11 | Terminal, 12 | }; 13 | 14 | use super::wait_key_press; 15 | 16 | pub fn handle_patchset_details( 17 | app: &mut App, 18 | key: KeyEvent, 19 | terminal: &mut Terminal, 20 | ) -> color_eyre::Result<()> { 21 | let patchset_details_and_actions = app.details_actions.as_mut().unwrap(); 22 | 23 | if key.modifiers.contains(KeyModifiers::SHIFT) { 24 | match key.code { 25 | KeyCode::Char('G') => patchset_details_and_actions.go_to_last_line(), 26 | KeyCode::Char('R') => { 27 | patchset_details_and_actions.toggle_reply_with_reviewed_by_action(true); 28 | } 29 | _ => {} 30 | } 31 | return Ok(()); 32 | } 33 | 34 | if key.modifiers.contains(KeyModifiers::CONTROL) { 35 | // TODO: Get preview sub-window height w/out coupling it to UI 36 | let terminal_height = terminal.size().unwrap().height as usize; 37 | match key.code { 38 | KeyCode::Char('b') => { 39 | patchset_details_and_actions.preview_scroll_up(terminal_height); 40 | } 41 | KeyCode::Char('f') => { 42 | patchset_details_and_actions.preview_scroll_down(terminal_height); 43 | } 44 | KeyCode::Char('u') => { 45 | patchset_details_and_actions.preview_scroll_up(terminal_height / 2); 46 | } 47 | KeyCode::Char('d') => { 48 | patchset_details_and_actions.preview_scroll_down(terminal_height / 2); 49 | } 50 | KeyCode::Char('t') => { 51 | let popup = 52 | ReviewTrailersPopUp::generate_trailers_popup(patchset_details_and_actions); 53 | app.popup = Some(popup); 54 | } 55 | _ => {} 56 | } 57 | return Ok(()); 58 | } 59 | 60 | match key.code { 61 | KeyCode::Char('?') => { 62 | let popup = generate_help_popup(); 63 | app.popup = Some(popup); 64 | } 65 | KeyCode::Esc | KeyCode::Char('q') => { 66 | let ps_da_clone = patchset_details_and_actions.last_screen.clone(); 67 | app.set_current_screen(ps_da_clone); 68 | app.reset_details_actions(); 69 | } 70 | KeyCode::Char('a') => { 71 | patchset_details_and_actions.toggle_apply_action(); 72 | } 73 | KeyCode::Char('j') | KeyCode::Down => { 74 | patchset_details_and_actions.preview_scroll_down(1); 75 | } 76 | KeyCode::Char('k') | KeyCode::Up => { 77 | patchset_details_and_actions.preview_scroll_up(1); 78 | } 79 | KeyCode::Char('h') | KeyCode::Left => { 80 | patchset_details_and_actions.preview_pan_left(); 81 | } 82 | KeyCode::Char('l') | KeyCode::Right => { 83 | patchset_details_and_actions.preview_pan_right(); 84 | } 85 | KeyCode::Char('0') => { 86 | patchset_details_and_actions.go_to_beg_of_line(); 87 | } 88 | KeyCode::Char('g') => { 89 | if let Ok(true) = wait_key_press('g', Duration::from_millis(500)) { 90 | patchset_details_and_actions.go_to_first_line(); 91 | } 92 | } 93 | KeyCode::Char('f') => { 94 | patchset_details_and_actions.toggle_preview_fullscreen(); 95 | } 96 | KeyCode::Char('n') => { 97 | patchset_details_and_actions.preview_next_patch(); 98 | } 99 | KeyCode::Char('p') => { 100 | patchset_details_and_actions.preview_previous_patch(); 101 | } 102 | KeyCode::Char('b') => { 103 | patchset_details_and_actions.toggle_bookmark_action(); 104 | } 105 | KeyCode::Char('r') => { 106 | patchset_details_and_actions.toggle_reply_with_reviewed_by_action(false); 107 | } 108 | KeyCode::Enter => { 109 | if patchset_details_and_actions.actions_require_user_io() { 110 | utils::setup_user_io(terminal)?; 111 | app.consolidate_patchset_actions()?; 112 | println!("\nPress ENTER continue..."); 113 | loop { 114 | if let Event::Key(key) = event::read()? { 115 | if key.kind == KeyEventKind::Press && key.code == KeyCode::Enter { 116 | break; 117 | } 118 | } 119 | } 120 | utils::teardown_user_io(terminal)?; 121 | } else { 122 | app.consolidate_patchset_actions()?; 123 | } 124 | app.set_current_screen(CurrentScreen::PatchsetDetails); 125 | } 126 | _ => {} 127 | } 128 | Ok(()) 129 | } 130 | 131 | pub fn generate_help_popup() -> Box { 132 | let popup = HelpPopUpBuilder::new() 133 | .title("Patchset Details and Actions") 134 | .description("This screen displays the details of a patchset and allows you to perform actions on it.\nA series of actions are available to you, they are:\n - Bookmark: Save the patchset for later\n - Reply with Reviewed-by: Reply to the patchset with a Reviewed-by tag") 135 | .keybind("ESC", "Exit") 136 | .keybind("ENTER", "Consolidate marked actions") 137 | .keybind("?", "Show this help screen") 138 | .keybind("j/🡇", "Scroll down") 139 | .keybind("k/🡅", "Scroll up") 140 | .keybind("h/🡄", "Pan left") 141 | .keybind("l/🡆", "Pan right") 142 | .keybind("0", "Go to start of line") 143 | .keybind("g", "Go to first line") 144 | .keybind("G", "Go to last line") 145 | .keybind("f", "Toggle fullscreen") 146 | .keybind("n", "Preview next patch") 147 | .keybind("p", "Preview previous patch") 148 | .keybind("b", "Toggle bookmark action") 149 | .keybind("r", "Toggle reply with Reviewed-by action") 150 | .keybind("Shift+r", "Toggle reply with Reviewed-by action for all patches") 151 | .keybind("Ctrl+t", "Show code-review trailers details") 152 | .build(); 153 | 154 | Box::new(popup) 155 | } 156 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::{config::HookBuilder, eyre}; 2 | use ratatui::layout::Position; 3 | use std::io::{self, stdout, Stdout}; 4 | use std::panic; 5 | 6 | use ratatui::{ 7 | backend::CrosstermBackend, 8 | crossterm::{ 9 | execute, 10 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 11 | }, 12 | prelude::Backend, 13 | Terminal, 14 | }; 15 | 16 | use crate::app::logging::Logger; 17 | 18 | /// A type alias for the terminal type used in this application 19 | pub type Tui = Terminal>; 20 | 21 | /// Initialize the terminal 22 | pub fn init() -> io::Result { 23 | execute!(stdout(), EnterAlternateScreen)?; 24 | enable_raw_mode()?; 25 | Terminal::new(CrosstermBackend::new(stdout())) 26 | } 27 | 28 | /// Restore the terminal to its original state 29 | pub fn restore() -> io::Result<()> { 30 | execute!(stdout(), LeaveAlternateScreen)?; 31 | disable_raw_mode()?; 32 | Ok(()) 33 | } 34 | 35 | /// This replaces the standard color_eyre panic and error hooks with hooks that 36 | /// restore the terminal before printing the panic or error. 37 | /// 38 | /// # Tests 39 | /// 40 | /// [tests::test_error_hook] 41 | /// [tests::test_panic_hook] 42 | pub fn install_hooks() -> color_eyre::Result<()> { 43 | let (panic_hook, eyre_hook) = HookBuilder::default().into_hooks(); 44 | 45 | // convert from a color_eyre PanicHook to a standard panic hook 46 | let panic_hook = panic_hook.into_panic_hook(); 47 | panic::set_hook(Box::new(move |panic_info| { 48 | restore().unwrap(); 49 | Logger::flush(); 50 | panic_hook(panic_info); 51 | })); 52 | 53 | // convert from a color_eyre EyreHook to a eyre ErrorHook 54 | let eyre_hook = eyre_hook.into_eyre_hook(); 55 | eyre::set_hook(Box::new( 56 | move |error: &(dyn std::error::Error + 'static)| { 57 | restore().unwrap(); 58 | eyre_hook(error) 59 | }, 60 | ))?; 61 | 62 | Ok(()) 63 | } 64 | 65 | pub fn setup_user_io(terminal: &mut Terminal) -> color_eyre::Result<()> { 66 | terminal.clear()?; 67 | terminal.set_cursor_position(Position::new(0, 0))?; 68 | terminal.show_cursor()?; 69 | disable_raw_mode()?; 70 | Ok(()) 71 | } 72 | 73 | pub fn teardown_user_io(terminal: &mut Terminal) -> color_eyre::Result<()> { 74 | enable_raw_mode()?; 75 | terminal.clear()?; 76 | Ok(()) 77 | } 78 | 79 | #[inline] 80 | /// Simply calls `which` to check if a binary exists 81 | /// 82 | /// # Tests 83 | /// 84 | /// [tests::test_binary_exists] 85 | pub fn binary_exists(binary: &str) -> bool { 86 | which::which(binary).is_ok() 87 | } 88 | 89 | #[macro_export] 90 | /// Macro that encapsulates a piece of code that takes long to run and displays a loading screen while it runs. 91 | /// 92 | /// This macro takes two arguments: the terminal and the title of the loading screen (anything that implements `Display`). 93 | /// After a `=>` token, you can pass the code that takes long to run. 94 | /// 95 | /// When the execution finishes, the macro will return the terminal. 96 | /// 97 | /// Important to notice that the code block will run in the same scope as the rest of the macro. 98 | /// Be aware that in Rust, when using `?` or `return` inside a closure, they apply to the outer function, 99 | /// not the closure itself. This can lead to unexpected behavior if you expect the closure to handle 100 | /// errors or return values independently of the enclosing function. 101 | /// 102 | /// # Example 103 | /// ```rust norun 104 | /// terminal = loading_screen! { terminal, "Loading stuff" => { 105 | /// // code that takes long to run 106 | /// }}; 107 | /// ``` 108 | macro_rules! loading_screen { 109 | { $terminal:expr, $title:expr => $inst:expr} => { 110 | { 111 | let loading = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); 112 | let loading_clone = std::sync::Arc::clone(&loading); 113 | let mut terminal = $terminal; 114 | 115 | let handle = std::thread::spawn(move || { 116 | while loading_clone.load(std::sync::atomic::Ordering::Relaxed) { 117 | terminal = $crate::ui::loading_screen::render(terminal, $title); 118 | std::thread::sleep(std::time::Duration::from_millis(200)); 119 | } 120 | 121 | terminal 122 | }); 123 | 124 | // we have to sleep so the loading thread completes at least one render 125 | std::thread::sleep(std::time::Duration::from_millis(200)); 126 | let inst_result = $inst; 127 | 128 | loading.store(false, std::sync::atomic::Ordering::Relaxed); 129 | 130 | let terminal = handle.join().unwrap(); 131 | 132 | inst_result?; 133 | 134 | terminal 135 | } 136 | }; 137 | } 138 | 139 | #[cfg(test)] 140 | mod tests { 141 | use std::sync::Once; 142 | 143 | use super::*; 144 | 145 | static INIT: Once = Once::new(); 146 | 147 | // Tests can be run in parallel, we don't want to override previously installed hooks 148 | fn setup() { 149 | INIT.call_once(|| { 150 | install_hooks().expect("Failed to install hooks"); 151 | }) 152 | } 153 | 154 | #[test] 155 | /// Tests [binary_exists] 156 | fn test_binary_exists() { 157 | // cargo should always exist since we are running the tests with `cargo test` 158 | assert!(super::binary_exists("cargo")); 159 | // there is no way this binary exists 160 | assert!(!super::binary_exists("there_is_no_way_this_binary_exists")); 161 | } 162 | 163 | #[test] 164 | /// Tests [install_hooks] 165 | fn test_error_hook() { 166 | setup(); 167 | 168 | let result: color_eyre::Result<()> = Err(eyre::eyre!("Test error")); 169 | 170 | // We can't directly test the hook's formatting, but we can verify 171 | // that handling an error doesn't cause unexpected panics 172 | match result { 173 | Ok(_) => panic!("Expected an error"), 174 | Err(e) => { 175 | let _ = format!("{:?}", e); 176 | } 177 | } 178 | } 179 | 180 | #[test] 181 | /// Tests [install_hooks] 182 | fn test_panic_hook() { 183 | setup(); 184 | 185 | let result = std::panic::catch_unwind(|| std::panic!("Test panic")); 186 | 187 | assert!(result.is_err()); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/ui/popup/help.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | crossterm::event::KeyCode, 3 | layout::Alignment, 4 | style::{Style, Stylize}, 5 | text::Line, 6 | widgets::{Clear, Paragraph}, 7 | }; 8 | use std::fmt::Display; 9 | 10 | use super::PopUp; 11 | 12 | /// A popup that displays a help message 13 | /// 14 | /// This popup is used to display a help message with a title, a description and a list of keybinds. 15 | /// It's meant to produce a new instance for each screen that needs a help popup and then the handler of this screen 16 | /// will be responsible for pushing the popup to the app when the user presses the help key (`?` is the suggested default) 17 | /// 18 | /// The title is displayed at the top center of the popup 19 | /// The description is displayed below the title and is optional 20 | /// The keybinds (also optional) are displayed in a table format with the key on the left and the help message on the right 21 | #[allow(dead_code)] 22 | #[derive(Debug)] 23 | pub struct HelpPopUp { 24 | title: Option, 25 | description: Option, 26 | keybinds: String, 27 | offset: (u16, u16), 28 | max_offset: (u16, u16), 29 | lines: u16, 30 | columns: u16, 31 | dimensions: (u16, u16), 32 | } 33 | 34 | /// A helper struct to build a `HelpPopUp` 35 | #[derive(Debug, Default)] 36 | pub struct HelpPopUpBuilder { 37 | title: Option, 38 | description: Option, 39 | keybinds: Vec<(String, String)>, 40 | } 41 | 42 | impl HelpPopUpBuilder { 43 | /// Creates a new empty `HelpPopUpBuilder` 44 | pub fn new() -> Self { 45 | Self { 46 | title: None, 47 | description: None, 48 | keybinds: Vec::new(), 49 | } 50 | } 51 | 52 | /// Defines the title of the popup 53 | /// 54 | /// The title is displayed at the top center of the popup and 55 | /// it's recommended to be short and be the name of the screen 56 | pub fn title(mut self, title: &str) -> Self { 57 | self.title = Some(title.to_string()); 58 | self 59 | } 60 | 61 | /// Defines the description of the popup 62 | /// 63 | /// The description is displayed below the title and is optional as its meant 64 | /// only to give some extra information about the screen for the user 65 | pub fn description(mut self, description: &str) -> Self { 66 | self.description = Some(description.to_string()); 67 | self 68 | } 69 | 70 | /// Adds a new help entry to the popup 71 | /// 72 | /// A help entry is composed of a key and a help message 73 | /// 74 | /// The keybinds are listed in order of insertion under a `Keybinds` section in the pop-up body 75 | pub fn keybind(mut self, key: K, help: H) -> Self { 76 | self.keybinds.push((key.to_string(), help.to_string())); 77 | self 78 | } 79 | 80 | /// Builds the `HelpPopUp` with the given parameters 81 | pub fn build(self) -> HelpPopUp { 82 | let key_len = self 83 | .keybinds 84 | .iter() 85 | .fold(0, |acc, (k, _)| if k.len() > acc { k.len() } else { acc }); 86 | 87 | let help = self.keybinds.iter().fold(String::new(), |acc, (k, v)| { 88 | acc + &format!("{:>width$}: {}\n", k, v, width = key_len) 89 | }); 90 | 91 | let lines = self.keybinds.len() as u16; 92 | 93 | let columns = self.keybinds.iter().fold(0, |acc, (k, v)| { 94 | let len = (k.len() + v.len()) as u16; 95 | if len > acc { 96 | len 97 | } else { 98 | acc 99 | } 100 | }); 101 | 102 | let dimensions = (50, 50); // TODO: Calculate percentage based on the lines and screen size 103 | 104 | HelpPopUp { 105 | title: self.title, 106 | description: self.description, 107 | keybinds: help, 108 | offset: (0, 0), 109 | max_offset: (lines, columns), 110 | columns: columns + 2, 111 | lines, 112 | dimensions, 113 | } 114 | } 115 | } 116 | 117 | impl PopUp for HelpPopUp { 118 | fn dimensions(&self) -> (u16, u16) { 119 | self.dimensions 120 | } 121 | 122 | fn render(&self, f: &mut ratatui::Frame, chunk: ratatui::prelude::Rect) { 123 | let block = ratatui::widgets::Block::default() 124 | .title(self.to_string()) 125 | .title_alignment(Alignment::Center) 126 | .title_style(ratatui::style::Style::default().bold().blue()) 127 | .title_bottom(Line::styled( 128 | "(ESC / q) Close", 129 | Style::default().bold().blue(), 130 | )) 131 | .borders(ratatui::widgets::Borders::ALL) 132 | .border_type(ratatui::widgets::BorderType::Double) 133 | .style(ratatui::style::Style::default()); 134 | 135 | // Push the description 136 | let text = if let Some(description) = &self.description { 137 | format!("{}\n\n", description) 138 | } else { 139 | String::new() 140 | }; 141 | 142 | // Push the help entries 143 | let text = if self.keybinds.is_empty() { 144 | text 145 | } else { 146 | format!("{} \u{1F836} Keybinds\n{}", text, self.keybinds) 147 | }; 148 | 149 | let text = Paragraph::new(text) 150 | .style(ratatui::style::Style::default()) 151 | .block(block) 152 | .alignment(ratatui::layout::Alignment::Left) 153 | .scroll(self.offset); 154 | 155 | f.render_widget(Clear, chunk); 156 | f.render_widget(text, chunk); 157 | } 158 | 159 | fn handle(&mut self, key: ratatui::crossterm::event::KeyEvent) -> color_eyre::Result<()> { 160 | match key.code { 161 | KeyCode::Up | KeyCode::Char('k') => { 162 | if self.offset.0 > 0 { 163 | self.offset.0 -= 1; 164 | } 165 | } 166 | KeyCode::Down | KeyCode::Char('j') => { 167 | if self.offset.0 < self.max_offset.0 { 168 | self.offset.0 += 1; 169 | } 170 | } 171 | KeyCode::Left | KeyCode::Char('h') => { 172 | if self.offset.1 > 0 { 173 | self.offset.1 -= 1; 174 | } 175 | } 176 | KeyCode::Right | KeyCode::Char('l') => { 177 | if self.offset.1 < self.max_offset.1 { 178 | self.offset.1 += 1; 179 | } 180 | } 181 | _ => {} 182 | } 183 | 184 | Ok(()) 185 | } 186 | } 187 | 188 | impl Display for HelpPopUp { 189 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 190 | if let Some(title) = &self.title { 191 | write!(f, "{}", title)?; 192 | } else { 193 | write!(f, "Help")?; 194 | } 195 | 196 | Ok(()) 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/app/config/tests.rs: -------------------------------------------------------------------------------- 1 | use serde_json::json; 2 | 3 | use super::*; 4 | 5 | use std::{process::Command, sync::Mutex}; 6 | 7 | static TEST_LOCK: Mutex<()> = Mutex::new(()); 8 | static mut TMP_CONFIG_SAMPLE_FILE_PATH: String = String::new(); 9 | 10 | fn setup_tmp_config_sample_file() { 11 | #[allow(static_mut_refs)] 12 | unsafe { 13 | // Create temporary file 14 | TMP_CONFIG_SAMPLE_FILE_PATH = String::from_utf8( 15 | Command::new("mktemp") 16 | .output() 17 | .expect("Failed to create temporary file!") 18 | .stdout, 19 | ) 20 | .expect("Couldn't convert `mktemp` output to String!"); 21 | 22 | // Copy contents from sample config file to temporary file 23 | fs::copy( 24 | "test_samples/app/config/config.json", 25 | &TMP_CONFIG_SAMPLE_FILE_PATH, 26 | ) 27 | .expect("Couldn't copy config sample file contents to temporary file!"); 28 | 29 | // Set temporary config file to be used instead of the git tracked sample file 30 | env::set_var("PATCH_HUB_CONFIG_PATH", &TMP_CONFIG_SAMPLE_FILE_PATH); 31 | }; 32 | } 33 | 34 | fn teardown_tmp_config_sample_file() { 35 | #[allow(static_mut_refs)] 36 | unsafe { 37 | // Sanitizing temporary file 38 | let _ = Command::new("rm") 39 | .arg(&TMP_CONFIG_SAMPLE_FILE_PATH) 40 | .output() 41 | .expect("Couldn't remove temporary config sample file!"); 42 | 43 | // Unset config file to used 44 | env::remove_var("PATCH_HUB_CONFIG_PATH"); 45 | } 46 | } 47 | 48 | #[test] 49 | /// Tests [`Config::build`] 50 | fn can_build_with_default_values() { 51 | let _lock = TEST_LOCK.lock().unwrap(); 52 | 53 | env::set_var("HOME", "/fake/home/path"); 54 | let config = Config::build(); 55 | 56 | assert_eq!(30, config.page_size()); 57 | assert_eq!( 58 | "/fake/home/path/.cache/patch_hub/patchsets", 59 | config.patchsets_cache_dir() 60 | ); 61 | assert_eq!( 62 | "/fake/home/path/.local/share/patch_hub/bookmarked_patchsets.json", 63 | config.bookmarked_patchsets_path() 64 | ); 65 | assert_eq!( 66 | "/fake/home/path/.local/share/patch_hub/mailing_lists.json", 67 | config.mailing_lists_path() 68 | ); 69 | assert_eq!( 70 | "/fake/home/path/.local/share/patch_hub/reviewed_patchsets.json", 71 | config.reviewed_patchsets_path() 72 | ); 73 | assert_eq!( 74 | "/fake/home/path/.local/share/patch_hub/logs", 75 | config.logs_path() 76 | ); 77 | assert_eq!( 78 | "--dry-run --suppress-cc=all", 79 | config.git_send_email_options() 80 | ); 81 | assert_eq!(30, config.max_log_age()); 82 | assert_eq!(HashSet::<&String>::new(), config.kernel_trees()); 83 | assert!(config.target_kernel_tree().is_none()); 84 | assert_eq!("", config.git_am_options()); 85 | assert_eq!("patchset-", config.git_am_branch_prefix()); 86 | } 87 | 88 | #[test] 89 | /// Tests [`Config::build`] 90 | fn can_build_with_config_file() { 91 | let _lock = TEST_LOCK.lock().unwrap(); 92 | 93 | setup_tmp_config_sample_file(); 94 | let config = Config::build(); 95 | teardown_tmp_config_sample_file(); 96 | 97 | assert_eq!(1234, config.page_size()); 98 | assert_eq!("/cachedir/path", config.patchsets_cache_dir()); 99 | assert_eq!( 100 | "/bookmarked/patchsets/path", 101 | config.bookmarked_patchsets_path() 102 | ); 103 | assert_eq!("/mailing/lists/path", config.mailing_lists_path()); 104 | assert_eq!("/reviewed/patchsets/path", config.reviewed_patchsets_path()); 105 | assert_eq!("/logs/path", config.logs_path()); 106 | assert_eq!( 107 | "--long-option value -s -h -o -r -t", 108 | config.git_send_email_options() 109 | ); 110 | assert_eq!(42, config.max_log_age()); 111 | assert_eq!( 112 | HashSet::from([&"linux".to_string(), &"amd-gfx".to_string()]), 113 | config.kernel_trees() 114 | ); 115 | assert_eq!( 116 | &KernelTree { 117 | path: "/home/user/linux".to_string(), 118 | branch: "master".to_string() 119 | }, 120 | config.get_kernel_tree("linux").unwrap() 121 | ); 122 | assert!(config.get_kernel_tree("invalid-id").is_none()); 123 | assert_eq!("linux", config.target_kernel_tree().as_ref().unwrap()); 124 | assert_eq!( 125 | "--foo-bar foobar -s -n -o -r -l -a -x", 126 | config.git_am_options() 127 | ); 128 | assert_eq!("really-creative-prefix-", config.git_am_branch_prefix()); 129 | } 130 | 131 | #[test] 132 | /// Tests [`Config::build`] 133 | fn can_build_with_env_vars() { 134 | let _lock = TEST_LOCK.lock().unwrap(); 135 | 136 | env::set_var("PATCH_HUB_PAGE_SIZE", "42"); 137 | env::set_var("PATCH_HUB_CACHE_DIR", "/fake/cache/path"); 138 | env::set_var("PATCH_HUB_DATA_DIR", "/fake/data/path"); 139 | env::set_var("PATCH_HUB_GIT_SEND_EMAIL_OPTIONS", "--option1 --option2"); 140 | let config = Config::build(); 141 | env::remove_var("PATCH_HUB_PAGE_SIZE"); 142 | env::remove_var("PATCH_HUB_CACHE_DIR"); 143 | env::remove_var("PATCH_HUB_DATA_DIR"); 144 | env::remove_var("PATCH_HUB_GIT_SEND_EMAIL_OPTIONS"); 145 | 146 | assert_eq!(42, config.page_size()); 147 | assert_eq!("/fake/cache/path/patchsets", config.patchsets_cache_dir()); 148 | assert_eq!( 149 | "/fake/data/path/bookmarked_patchsets.json", 150 | config.bookmarked_patchsets_path() 151 | ); 152 | assert_eq!( 153 | "/fake/data/path/mailing_lists.json", 154 | config.mailing_lists_path() 155 | ); 156 | assert_eq!( 157 | "/fake/data/path/reviewed_patchsets.json", 158 | config.reviewed_patchsets_path() 159 | ); 160 | assert_eq!("/fake/data/path/logs", config.logs_path()); 161 | assert_eq!("--option1 --option2", config.git_send_email_options()); 162 | 163 | env::remove_var("PATCH_HUB_CACHE_DIR"); 164 | env::remove_var("PATCH_HUB_DATA_DIR"); 165 | } 166 | 167 | #[test] 168 | /// Tests [`Config::build`] 169 | fn test_config_precedence() { 170 | let _lock = TEST_LOCK.lock().unwrap(); 171 | 172 | // Default values 173 | env::set_var("HOME", "/fake/home/path"); 174 | let config = Config::build(); 175 | assert_eq!(30, config.page_size()); 176 | 177 | // Config file should have precedence over default values 178 | setup_tmp_config_sample_file(); 179 | let config = Config::build(); 180 | assert_eq!(1234, config.page_size()); 181 | 182 | // Env vars should have precedence over default values 183 | env::set_var("PATCH_HUB_PAGE_SIZE", "42"); 184 | let config = Config::build(); 185 | assert_eq!(42, config.page_size()); 186 | 187 | teardown_tmp_config_sample_file(); 188 | env::remove_var("PATCH_HUB_PAGE_SIZE"); 189 | } 190 | 191 | #[test] 192 | fn test_deserialize_config_with_missing_field() { 193 | // Example JSON string that doesn't contain `page_size` but has `max_log_age` set to 500. 194 | let json_data = json!({ 195 | "max_log_age": 500 196 | }); 197 | 198 | let config: Config = serde_json::from_value(json_data).unwrap(); 199 | 200 | // Assert that `page_size` is set to the default value (25) 201 | assert_eq!(config.page_size, 30); 202 | 203 | // Assert that `max_log_age` is set to the custom value 204 | assert_eq!(config.max_log_age, 500); 205 | } 206 | -------------------------------------------------------------------------------- /src/app/patch_renderer.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::Display, 3 | io::Write, 4 | process::{Command, Stdio}, 5 | }; 6 | 7 | use color_eyre::eyre::eyre; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | use super::logging::Logger; 11 | 12 | #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default)] 13 | pub enum PatchRenderer { 14 | #[default] 15 | #[serde(rename = "default")] 16 | Default, 17 | #[serde(rename = "bat")] 18 | Bat, 19 | #[serde(rename = "delta")] 20 | Delta, 21 | #[serde(rename = "diff-so-fancy")] 22 | DiffSoFancy, 23 | } 24 | 25 | impl From for PatchRenderer { 26 | fn from(value: String) -> Self { 27 | match value.as_str() { 28 | "bat" => PatchRenderer::Bat, 29 | "delta" => PatchRenderer::Delta, 30 | "diff-so-fancy" => PatchRenderer::DiffSoFancy, 31 | _ => PatchRenderer::Default, 32 | } 33 | } 34 | } 35 | 36 | impl From<&str> for PatchRenderer { 37 | fn from(value: &str) -> Self { 38 | match value { 39 | "bat" => PatchRenderer::Bat, 40 | "delta" => PatchRenderer::Delta, 41 | "diff-so-fancy" => PatchRenderer::DiffSoFancy, 42 | _ => PatchRenderer::Default, 43 | } 44 | } 45 | } 46 | 47 | impl Display for PatchRenderer { 48 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 49 | match self { 50 | PatchRenderer::Default => write!(f, "default"), 51 | PatchRenderer::Bat => write!(f, "bat"), 52 | PatchRenderer::Delta => write!(f, "delta"), 53 | PatchRenderer::DiffSoFancy => write!(f, "diff-so-fancy"), 54 | } 55 | } 56 | } 57 | 58 | pub fn render_patch_preview(raw: &str, renderer: &PatchRenderer) -> color_eyre::Result { 59 | let text = match renderer { 60 | PatchRenderer::Default => Ok(raw.to_string()), 61 | PatchRenderer::Bat => bat_patch_renderer(raw), 62 | PatchRenderer::Delta => delta_patch_renderer(raw), 63 | PatchRenderer::DiffSoFancy => diff_so_fancy_renderer(raw), 64 | }?; 65 | 66 | Ok(text) 67 | } 68 | 69 | /// Cleans patch contents before rendering for preview. Currently, it only trims 70 | /// the trailing signature delimiter (the `--` at the end of the patch) if it 71 | /// exists, as it is incorrectly rendered as a deletion by diff renderers. 72 | fn clean_patch_for_preview(patch: &str) -> String { 73 | let lines: Vec<&str> = patch.lines().collect(); 74 | 75 | if let Some(sig_pos) = lines.iter().position(|&line| line.trim() == "--") { 76 | lines[..sig_pos].join("\n") 77 | } else { 78 | patch.to_string() 79 | } 80 | } 81 | 82 | /// Renders a patch using the `bat` command line tool. 83 | /// 84 | /// # Errors 85 | /// 86 | /// If bat isn't installed or if the command fails, an error will be returned. 87 | /// 88 | /// # Tests 89 | /// 90 | /// [tests::test_bat_patch_renderer] 91 | fn bat_patch_renderer(patch: &str) -> color_eyre::Result { 92 | let cleaned_patch = clean_patch_for_preview(patch); 93 | 94 | let mut bat = Command::new("bat") 95 | .arg("-pp") 96 | .arg("-f") 97 | .arg("-l") 98 | .arg("patch") 99 | .stdin(Stdio::piped()) 100 | .stdout(Stdio::piped()) 101 | .spawn() 102 | .map_err(|e| { 103 | Logger::error(format!("Failed to spawn bat for patch preview: {}", e)); 104 | e 105 | })?; 106 | 107 | bat.stdin 108 | .as_mut() 109 | .ok_or_else(|| eyre!("Failed to get stdin handle"))? 110 | .write_all(cleaned_patch.as_bytes())?; 111 | let output = bat.wait_with_output()?; 112 | Ok(String::from_utf8(output.stdout)?) 113 | } 114 | 115 | /// Renders a patch using the `delta` command line tool. 116 | /// 117 | /// # Errors 118 | /// 119 | /// If delta isn't installed or if the command fails, an error will be returned. 120 | /// 121 | /// # Tests 122 | /// 123 | /// [tests::test_delta_patch_renderer] 124 | fn delta_patch_renderer(patch: &str) -> color_eyre::Result { 125 | let cleaned_patch = clean_patch_for_preview(patch); 126 | 127 | let mut delta = Command::new("delta") 128 | .arg("--pager") 129 | .arg("less") 130 | .arg("--no-gitconfig") 131 | .arg("--paging") 132 | .arg("never") 133 | .arg("-w") 134 | .arg("130") 135 | .stdin(Stdio::piped()) 136 | .stdout(Stdio::piped()) 137 | .spawn() 138 | .map_err(|e| { 139 | Logger::error(format!("Failed to spawn delta for patch preview: {}", e)); 140 | e 141 | })?; 142 | 143 | delta 144 | .stdin 145 | .as_mut() 146 | .ok_or_else(|| eyre!("Failed to get stdin handle"))? 147 | .write_all(cleaned_patch.as_bytes())?; 148 | let output = delta.wait_with_output()?; 149 | Ok(String::from_utf8(output.stdout)?) 150 | } 151 | 152 | /// Renders a patch using the `diff-so-fancy` command line tool. 153 | /// 154 | /// # Errors 155 | /// 156 | /// If diff-so-fancy isn't installed or if the command fails, an error will be returned. 157 | /// 158 | /// # Tests 159 | /// 160 | /// [tests::test_diff_so_fancy_renderer] 161 | fn diff_so_fancy_renderer(patch: &str) -> color_eyre::Result { 162 | let cleaned_patch = clean_patch_for_preview(patch); 163 | 164 | let mut dsf = Command::new("diff-so-fancy") 165 | .stdin(Stdio::piped()) 166 | .stdout(Stdio::piped()) 167 | .spawn() 168 | .map_err(|e| { 169 | Logger::error(format!( 170 | "Failed to spawn diff-so-fancy for patch preview: {}", 171 | e 172 | )); 173 | e 174 | })?; 175 | 176 | dsf.stdin 177 | .as_mut() 178 | .ok_or_else(|| eyre!("Failed to get stdin handle"))? 179 | .write_all(cleaned_patch.as_bytes())?; 180 | let output = dsf.wait_with_output()?; 181 | Ok(String::from_utf8(output.stdout)?) 182 | } 183 | 184 | #[cfg(test)] 185 | mod tests { 186 | use std::fs; 187 | 188 | use super::*; 189 | 190 | const PATCH_SAMPLE: &str = "diff --git a/file.txt b/file.txt 191 | index 83db48f..e3b0c44 100644 192 | --- a/file.txt 193 | +++ b/file.txt 194 | @@ -1 +1 @@ 195 | -Hello, world! 196 | +Hello, Rust! 197 | "; 198 | 199 | #[test] 200 | #[ignore = "optional-dependency"] 201 | /// Tests [bat_patch_renderer] 202 | fn test_bat_patch_renderer() { 203 | let result = bat_patch_renderer(PATCH_SAMPLE); 204 | assert!(result.is_ok()); 205 | let rendered_patch = result.unwrap(); 206 | assert_eq!( 207 | fs::read_to_string("test_samples/ui/render_patchset/expected_bat.diff").unwrap(), 208 | rendered_patch, 209 | "Wrong rendering of bat" 210 | ); 211 | } 212 | 213 | #[test] 214 | #[ignore = "optional-dependency"] 215 | /// Tests [delta_patch_renderer] 216 | fn test_delta_patch_renderer() { 217 | let result = delta_patch_renderer(PATCH_SAMPLE); 218 | assert!(result.is_ok()); 219 | let rendered_patch = result.unwrap(); 220 | assert_eq!( 221 | fs::read_to_string("test_samples/ui/render_patchset/expected_delta.diff").unwrap(), 222 | rendered_patch, 223 | "Wrong rendering of delta" 224 | ); 225 | } 226 | 227 | #[test] 228 | #[ignore = "optional-dependency"] 229 | /// Tests [diff_so_fancy_renderer] 230 | fn test_diff_so_fancy_renderer() { 231 | let result = diff_so_fancy_renderer(PATCH_SAMPLE); 232 | assert!(result.is_ok()); 233 | let rendered_patch = result.unwrap(); 234 | assert_eq!( 235 | fs::read_to_string("test_samples/ui/render_patchset/expected_diff-so-fancy.diff") 236 | .unwrap(), 237 | rendered_patch, 238 | "Wrong rendering of diff-so-fancy" 239 | ); 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/app/config.rs: -------------------------------------------------------------------------------- 1 | use derive_getters::Getters; 2 | use proc_macros::serde_individual_default; 3 | use serde::{Deserialize, Serialize}; 4 | use std::{ 5 | collections::{HashMap, HashSet}, 6 | env, 7 | fs::{self, File}, 8 | io, 9 | path::Path, 10 | }; 11 | 12 | pub const DEFAULT_CONFIG_PATH_SUFFIX: &str = ".config/patch-hub/config.json"; 13 | 14 | use super::{cover_renderer::CoverRenderer, patch_renderer::PatchRenderer}; 15 | 16 | #[cfg(test)] 17 | mod tests; 18 | 19 | #[derive(Serialize, Getters)] 20 | #[serde_individual_default] 21 | pub struct Config { 22 | #[getter(skip)] 23 | page_size: usize, 24 | patchsets_cache_dir: String, 25 | bookmarked_patchsets_path: String, 26 | mailing_lists_path: String, 27 | reviewed_patchsets_path: String, 28 | /// Logs directory 29 | logs_path: String, 30 | git_send_email_options: String, 31 | /// Base directory for all patch-hub cache 32 | cache_dir: String, 33 | /// Base directory for all patch-hub cache 34 | data_dir: String, 35 | /// Renderer to use for patch previews 36 | patch_renderer: PatchRenderer, 37 | /// Renderer to use for patchset covers 38 | cover_renderer: CoverRenderer, 39 | /// Maximum age of a log file in days 40 | max_log_age: usize, 41 | #[getter(skip)] 42 | /// Map of tracked kernel trees 43 | kernel_trees: HashMap, 44 | /// Target kernel tree to run actions 45 | target_kernel_tree: Option, 46 | /// Flags to be use with git am command when applying patches 47 | git_am_options: String, 48 | git_am_branch_prefix: String, 49 | } 50 | 51 | #[derive(Debug, Serialize, Deserialize, Getters, Eq, PartialEq, Clone)] 52 | pub struct KernelTree { 53 | /// Path to kernel tree in the filesystem 54 | path: String, 55 | /// Target branch 56 | branch: String, 57 | } 58 | 59 | impl Default for Config { 60 | fn default() -> Self { 61 | let home = env::var("HOME").unwrap_or_else(|_| { 62 | eprintln!("$HOME environment variable not set, using current directory"); 63 | ".".to_string() 64 | }); 65 | let cache_dir = format!("{}/.cache/patch_hub", home); 66 | let data_dir = format!("{}/.local/share/patch_hub", home); 67 | 68 | Config { 69 | page_size: 30, 70 | patchsets_cache_dir: format!("{cache_dir}/patchsets"), 71 | bookmarked_patchsets_path: format!("{data_dir}/bookmarked_patchsets.json"), 72 | mailing_lists_path: format!("{data_dir}/mailing_lists.json"), 73 | reviewed_patchsets_path: format!("{data_dir}/reviewed_patchsets.json"), 74 | logs_path: format!("{data_dir}/logs"), 75 | git_send_email_options: "--dry-run --suppress-cc=all".to_string(), 76 | patch_renderer: Default::default(), 77 | cover_renderer: Default::default(), 78 | cache_dir, 79 | data_dir, 80 | max_log_age: 30, 81 | kernel_trees: HashMap::new(), 82 | target_kernel_tree: None, 83 | git_am_options: String::new(), 84 | git_am_branch_prefix: String::from("patchset-"), 85 | } 86 | } 87 | } 88 | 89 | impl Config { 90 | /// Loads the configuration for patch-hub from the config file. 91 | /// 92 | /// Returns the default config if the config file is not found or if it's not a valid JSON. 93 | fn load_file() -> Config { 94 | let config_path = Config::get_config_path(); 95 | 96 | if Path::new(&config_path).is_file() { 97 | match fs::read_to_string(&config_path) { 98 | Ok(file_contents) => match serde_json::from_str(&file_contents) { 99 | Ok(config) => return config, 100 | Err(e) => eprintln!("Failed to parse config file {}: {}", config_path, e), 101 | }, 102 | Err(e) => { 103 | eprintln!("Failed to read config file {}: {}", config_path, e) 104 | } 105 | } 106 | } 107 | 108 | Config::default() 109 | } 110 | 111 | fn override_with_env_vars(&mut self) { 112 | if let Ok(page_size) = env::var("PATCH_HUB_PAGE_SIZE") { 113 | self.page_size = page_size.parse().unwrap(); 114 | }; 115 | 116 | if let Ok(cache_dir) = env::var("PATCH_HUB_CACHE_DIR") { 117 | self.set_cache_dir(cache_dir); 118 | }; 119 | 120 | if let Ok(data_dir) = env::var("PATCH_HUB_DATA_DIR") { 121 | self.set_data_dir(data_dir); 122 | }; 123 | 124 | if let Ok(git_send_email_options) = env::var("PATCH_HUB_GIT_SEND_EMAIL_OPTIONS") { 125 | self.git_send_email_options = git_send_email_options; 126 | }; 127 | 128 | if let Ok(patch_renderer) = env::var("PATCH_HUB_PATCH_RENDERER") { 129 | self.patch_renderer = patch_renderer.into(); 130 | }; 131 | } 132 | 133 | /// # Tests 134 | /// 135 | /// [tests::can_build_with_default_values] 136 | /// [tests::can_build_with_config_file] 137 | /// [tests::can_build_with_env_vars] 138 | /// [tests::test_config_precedence] 139 | pub fn build() -> Self { 140 | let mut config = Self::load_file(); 141 | config.save_patch_hub_config().unwrap_or_else(|e| { 142 | eprintln!("Failed to save default config: {}", e); 143 | }); 144 | config.override_with_env_vars(); 145 | 146 | config 147 | } 148 | 149 | pub fn page_size(&self) -> usize { 150 | self.page_size 151 | } 152 | 153 | pub fn set_page_size(&mut self, page_size: usize) { 154 | self.page_size = page_size; 155 | } 156 | 157 | pub fn set_cache_dir(&mut self, cache_dir: String) { 158 | self.patchsets_cache_dir = format!("{cache_dir}/patchsets"); 159 | self.cache_dir = cache_dir; 160 | } 161 | 162 | pub fn set_data_dir(&mut self, data_dir: String) { 163 | self.bookmarked_patchsets_path = format!("{data_dir}/bookmarked_patchsets.json"); 164 | self.mailing_lists_path = format!("{data_dir}/mailing_lists.json"); 165 | self.reviewed_patchsets_path = format!("{data_dir}/reviewed_patchsets.json"); 166 | self.logs_path = format!("{data_dir}/logs"); 167 | self.data_dir = data_dir; 168 | } 169 | 170 | pub fn set_git_send_email_option(&mut self, git_send_email_options: String) { 171 | self.git_send_email_options = git_send_email_options; 172 | } 173 | 174 | pub fn set_git_am_option(&mut self, git_am_options: String) { 175 | self.git_am_options = git_am_options; 176 | } 177 | 178 | #[allow(dead_code)] 179 | /// Returns the list of names of the registered kernel trees 180 | pub fn kernel_trees(&self) -> HashSet<&String> { 181 | self.kernel_trees.keys().collect::>() 182 | } 183 | 184 | /// Returns a reference to the `KernelTree` mapped by `@kernel_tree_id`, if 185 | /// it exists. 186 | #[allow(dead_code)] 187 | pub fn get_kernel_tree(&self, kernel_tree_id: &str) -> Option<&KernelTree> { 188 | if let Some(kernel_tree) = self.kernel_trees.get(kernel_tree_id) { 189 | Some(kernel_tree) 190 | } else { 191 | None 192 | } 193 | } 194 | 195 | pub fn set_patch_renderer(&mut self, patch_renderer: PatchRenderer) { 196 | self.patch_renderer = patch_renderer; 197 | } 198 | 199 | pub fn set_cover_renderer(&mut self, cover_renderer: CoverRenderer) { 200 | self.cover_renderer = cover_renderer; 201 | } 202 | 203 | pub fn set_max_log_age(&mut self, max_log_age: usize) { 204 | self.max_log_age = max_log_age; 205 | } 206 | 207 | pub fn save_patch_hub_config(&self) -> io::Result<()> { 208 | let config_path = Config::get_config_path(); 209 | 210 | let config_path = Path::new(&config_path); 211 | // We need to assure that the parent dir of `config_path` exists 212 | if let Some(parent_dir) = Path::parent(config_path) { 213 | fs::create_dir_all(parent_dir)?; 214 | } 215 | 216 | let tmp_filename = format!("{}.tmp", config_path.display()); 217 | { 218 | let tmp_file = File::create(&tmp_filename)?; 219 | serde_json::to_writer_pretty(tmp_file, self)?; 220 | } 221 | fs::rename(tmp_filename, config_path)?; 222 | Ok(()) 223 | } 224 | 225 | /// Returns the current Config path. 226 | /// 227 | /// Resolves to env var PATCH_HUB_CONFIG_PATH, if set, and to env var HOME 228 | /// plus constant DEFAULT_CONFIG_PATH_SUFFIX, otherwise. 229 | fn get_config_path() -> String { 230 | env::var("PATCH_HUB_CONFIG_PATH").unwrap_or(format!( 231 | "{}/{}", 232 | env::var("HOME").unwrap(), 233 | DEFAULT_CONFIG_PATH_SUFFIX 234 | )) 235 | } 236 | 237 | /// Creates the needed directories if they don't exist. 238 | /// The directories are defined during the Config build. 239 | /// 240 | /// This function must be called as soon as the Config is built so no other function attempt to use an inexistent folder. 241 | pub fn create_dirs(&self) { 242 | let paths = vec![ 243 | &self.cache_dir, 244 | &self.data_dir, 245 | &self.patchsets_cache_dir, 246 | &self.logs_path, 247 | ]; 248 | 249 | for path in paths { 250 | if fs::metadata(path).is_err() { 251 | fs::create_dir_all(path).unwrap(); 252 | } 253 | } 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /src/app/screens/edit_config.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, fmt::Display, path::Path}; 2 | 3 | use crate::app::config::Config; 4 | use color_eyre::eyre::bail; 5 | use derive_getters::Getters; 6 | 7 | #[derive(Debug, Getters)] 8 | pub struct EditConfig { 9 | #[getter(skip)] 10 | config_buffer: HashMap, 11 | highlighted: usize, 12 | is_editing: bool, 13 | curr_edit: String, 14 | } 15 | 16 | impl EditConfig { 17 | pub fn new(config: &Config) -> Self { 18 | let mut config_buffer = HashMap::new(); 19 | config_buffer.insert(EditableConfig::PageSize, config.page_size().to_string()); 20 | config_buffer.insert(EditableConfig::CacheDir, config.cache_dir().to_string()); 21 | config_buffer.insert(EditableConfig::DataDir, config.data_dir().to_string()); 22 | config_buffer.insert( 23 | EditableConfig::GitSendEmailOpt, 24 | config.git_send_email_options().to_string(), 25 | ); 26 | config_buffer.insert( 27 | EditableConfig::GitAmOpt, 28 | config.git_am_options().to_string(), 29 | ); 30 | config_buffer.insert( 31 | EditableConfig::PatchRenderer, 32 | config.patch_renderer().to_string(), 33 | ); 34 | config_buffer.insert( 35 | EditableConfig::CoverRenderer, 36 | config.cover_renderer().to_string(), 37 | ); 38 | config_buffer.insert(EditableConfig::MaxLogAge, config.max_log_age().to_string()); 39 | 40 | EditConfig { 41 | config_buffer, 42 | highlighted: 0, 43 | is_editing: false, 44 | curr_edit: String::new(), 45 | } 46 | } 47 | 48 | /// Get the number of config entries in the config buffer 49 | pub fn config_count(&self) -> usize { 50 | self.config_buffer.len() 51 | } 52 | 53 | /// Get the config entry at the given index 54 | pub fn config(&self, i: usize) -> Option<(String, String)> { 55 | EditableConfig::try_from(i) 56 | .ok() 57 | .and_then(|editable_config| { 58 | self.config_buffer 59 | .get(&editable_config) 60 | .map(|value| (editable_config.to_string(), value.clone())) 61 | }) 62 | } 63 | 64 | /// Toggle editing mode 65 | pub fn toggle_editing(&mut self) { 66 | if !self.is_editing { 67 | if let Ok(editable_config) = EditableConfig::try_from(self.highlighted()) { 68 | if let Some(value) = self.config_buffer.get(&editable_config) { 69 | self.curr_edit = value.clone(); 70 | } 71 | } 72 | } 73 | self.is_editing = !self.is_editing; 74 | } 75 | 76 | /// Move the highlight to the previous entry 77 | pub fn highlight_prev(&mut self) { 78 | if self.highlighted > 0 { 79 | self.highlighted -= 1; 80 | } 81 | } 82 | 83 | /// Move the highlight to the next entry 84 | pub fn highlight_next(&mut self) { 85 | if self.highlighted + 1 < self.config_buffer.len() { 86 | self.highlighted += 1; 87 | } 88 | } 89 | 90 | /// Remove the last char from the current editing value if not empty 91 | pub fn backspace_edit(&mut self) { 92 | if !self.curr_edit.is_empty() { 93 | self.curr_edit.pop(); 94 | } 95 | } 96 | 97 | /// Appends a new char to the current editing value 98 | pub fn append_edit(&mut self, ch: char) { 99 | self.curr_edit.push(ch); 100 | } 101 | 102 | /// Clear the current editing value 103 | pub fn clear_edit(&mut self) { 104 | self.curr_edit.clear(); 105 | } 106 | 107 | /// Push the current edit value to the config buffer 108 | pub fn stage_edit(&mut self) { 109 | if let Ok(editable_config) = EditableConfig::try_from(self.highlighted) { 110 | self.config_buffer 111 | .insert(editable_config, std::mem::take(&mut self.curr_edit)); 112 | } 113 | } 114 | } 115 | 116 | impl EditConfig { 117 | fn extract_config_buffer_val(&mut self, editable_config: &EditableConfig) -> String { 118 | let mut ret_value = String::new(); 119 | if let Some(config_value) = self.config_buffer.get_mut(editable_config) { 120 | std::mem::swap(&mut ret_value, config_value); 121 | } 122 | ret_value 123 | } 124 | 125 | /// Extracts the page size from the config 126 | /// 127 | /// # Errors 128 | /// 129 | /// Returns an error if the page size inserted string is not a valid integer 130 | pub fn page_size(&mut self) -> Result { 131 | match self 132 | .extract_config_buffer_val(&EditableConfig::PageSize) 133 | .parse::() 134 | { 135 | Ok(value) => Ok(value), 136 | Err(_) => Err(()), 137 | } 138 | } 139 | 140 | fn is_valid_dir(dir_path: &str) -> bool { 141 | let path_to_check = Path::new(dir_path); 142 | 143 | if path_to_check.exists() && path_to_check.is_dir() { 144 | true 145 | } else { 146 | std::fs::create_dir_all(path_to_check).is_ok() 147 | } 148 | } 149 | 150 | /// Extracts the cache directory from the config 151 | /// 152 | /// # Errors 153 | /// 154 | /// Returns an error if the cache directory is not a valid directory 155 | pub fn cache_dir(&mut self) -> Result { 156 | let cache_dir = self.extract_config_buffer_val(&EditableConfig::CacheDir); 157 | match Self::is_valid_dir(&cache_dir) { 158 | true => Ok(cache_dir), 159 | false => Err(()), 160 | } 161 | } 162 | 163 | /// Extracts the data directory from the config 164 | /// 165 | /// # Errors 166 | /// 167 | /// Returns an error if the data directory is not a valid directory 168 | pub fn data_dir(&mut self) -> Result { 169 | let data_dir = self.extract_config_buffer_val(&EditableConfig::DataDir); 170 | match Self::is_valid_dir(&data_dir) { 171 | true => Ok(data_dir), 172 | false => Err(()), 173 | } 174 | } 175 | 176 | /// Extracts the `git send email` option from the config 177 | pub fn git_send_email_option(&mut self) -> Result { 178 | let git_send_emial_option = 179 | self.extract_config_buffer_val(&EditableConfig::GitSendEmailOpt); 180 | // TODO: Check if the option is valid 181 | Ok(git_send_emial_option) 182 | } 183 | 184 | /// Extracts the `git am` option from the config 185 | pub fn git_am_option(&mut self) -> Result { 186 | let git_am_option = self.extract_config_buffer_val(&EditableConfig::GitAmOpt); 187 | Ok(git_am_option) 188 | } 189 | 190 | pub fn extract_patch_renderer(&mut self) -> Result { 191 | let patch_renderer = self.extract_config_buffer_val(&EditableConfig::PatchRenderer); 192 | Ok(patch_renderer) 193 | } 194 | 195 | pub fn extract_cover_renderer(&mut self) -> Result { 196 | let cover_renderer = self.extract_config_buffer_val(&EditableConfig::CoverRenderer); 197 | Ok(cover_renderer) 198 | } 199 | 200 | /// Extracts the max log age from the config 201 | /// 202 | /// # Errors 203 | /// 204 | /// Returns an error if the max log age inserted string is not a valid integer 205 | pub fn max_log_age(&mut self) -> Result { 206 | match self 207 | .extract_config_buffer_val(&EditableConfig::MaxLogAge) 208 | .parse::() 209 | { 210 | Ok(value) => Ok(value), 211 | Err(_) => Err(()), 212 | } 213 | } 214 | } 215 | 216 | #[derive(Debug, Hash, Eq, PartialEq)] 217 | enum EditableConfig { 218 | PageSize, 219 | CacheDir, 220 | DataDir, 221 | GitSendEmailOpt, 222 | GitAmOpt, 223 | PatchRenderer, 224 | CoverRenderer, 225 | MaxLogAge, 226 | } 227 | 228 | impl TryFrom for EditableConfig { 229 | type Error = color_eyre::Report; 230 | 231 | fn try_from(value: usize) -> Result { 232 | match value { 233 | 0 => Ok(EditableConfig::PageSize), 234 | 1 => Ok(EditableConfig::CacheDir), 235 | 2 => Ok(EditableConfig::DataDir), 236 | 3 => Ok(EditableConfig::GitSendEmailOpt), 237 | 4 => Ok(EditableConfig::GitAmOpt), 238 | 5 => Ok(EditableConfig::PatchRenderer), 239 | 6 => Ok(EditableConfig::CoverRenderer), 240 | 7 => Ok(EditableConfig::MaxLogAge), 241 | _ => bail!("Invalid index {} for EditableConfig", value), // Handle out of bounds 242 | } 243 | } 244 | } 245 | 246 | impl Display for EditableConfig { 247 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 248 | match self { 249 | EditableConfig::PageSize => write!(f, "Page Size"), 250 | EditableConfig::CacheDir => write!(f, "Cache Directory"), 251 | EditableConfig::DataDir => write!(f, "Data Directory"), 252 | EditableConfig::PatchRenderer => { 253 | write!(f, "Patch Renderer (bat, delta, diff-so-fancy)") 254 | } 255 | EditableConfig::CoverRenderer => { 256 | write!(f, "Cover Renderer (bat)") 257 | } 258 | EditableConfig::GitSendEmailOpt => write!(f, "`git send email` option"), 259 | EditableConfig::MaxLogAge => write!(f, "Max Log Age (0 = forever)"), 260 | EditableConfig::GitAmOpt => write!(f, "`git am` option"), 261 | } 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `patch-hub` 2 | 3 | Thank you for your interest in contributing to `patch-hub`! This document outlines the process for contributing to the project and provides guidelines to ensure your contributions align with the project's standards. 4 | 5 | ## Table of Contents 6 | 7 | 1. [Code of Conduct](#code-of-conduct) 8 | 2. [Getting Started](#getting-started) 9 | 3. [Development Workflow](#development-workflow) 10 | 4. [Coding Style](#coding-style) 11 | 5. [Commit Guidelines](#commit-guidelines) 12 | 6. [Pull Request Guidelines](#pull-request-guidelines) 13 | 7. [Issue Reporting](#issue-reporting) 14 | 8. [Communication](#communication) 15 | 16 | ## Code of Conduct 17 | 18 | `patch-hub` adheres to the [Contributor Covenant of the Linux Kernel](https://docs.kernel.org/process/code-of-conduct.html) development community. In general, we expect all developers to: 19 | 20 | - Be respectful and inclusive. 21 | - Value diverse perspectives. 22 | - Provide constructive feedback and gracefully accept constructive criticism. 23 | - Focus on the technical merit of contributions and what benefits the community. 24 | 25 | ## Getting Started 26 | 27 | ### Prerequisites 28 | 29 | - [Rust](https://www.rust-lang.org/) 30 | - [Git](https://git-scm.com/) 31 | - [A GitHub account](https://github.com/signup) 32 | - (Optional) [Pre-commit](https://pre-commit.com/) 33 | 34 | ### Setting Up Your Development Environment 35 | 36 | 1. [Fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) the repository on GitHub. 37 | 2. [Clone](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo#cloning-your-forked-repository) your fork. 38 | 3. Add the [upstream repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo#configuring-git-to-sync-your-fork-with-the-upstream-repository) as a remote: 39 | 40 | ## Development Cycle and Branches 41 | 42 | ### Branch Management 43 | 44 | The development cycle relies on two branches, similar to [how kw does it](https://kworkflow.org/content/howtocontribute.html#development-cycle-and-branches). 45 | 46 | 1. `master`: The stable branch that contains the latest tested and approved code. 47 | 2. `unstable`: The main development branch where active work happens. Features and fixes are merged here before reaching the master branch. 48 | 49 | From time to time, the `unstable` branch is merged into `master` to create a new version and release. 50 | 51 | Always ensure your branch is up to date with `unstable` before starting to work on a contribution. 52 | 53 | ### Development Workflow 54 | 55 | 1. [**Keep your fork updated**](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) 56 | 2. **Create a branch**: 57 | ```bash 58 | git checkout -b your-branch-name 59 | ``` 60 | The branch names are mostly irrevelant; however, it is advisable to use clearly discernable and meaningful names. Prefixing with a label denoting the type of change (e.g., `refactor/apply-patchset` or `doc/add-readme`) you would like to make is also a good practice. 61 | 62 | 3. **Plan and identify your contribution**: 63 | - Clearly define what you are trying to achieve with your contribution. 64 | - If fixing an issue, ensure you fully understand the problem and check for any related discussions. 65 | - If adding a feature or refactoring, consider the overall design and any potential implications. 66 | - Discuss major changes with maintainers before implementation, if necessary. 67 | 68 | 4. **Implement your contribution**: 69 | - Add tests for new functionality (if any). 70 | - Update documentation as needed. 71 | 72 | 5. **Run tests and checks locally**: 73 | ```bash 74 | cargo test 75 | cargo lint 76 | cargo fmt --all 77 | ``` 78 | Alternatively, you may skip this step, commit, and push your changes to your fork to let our GitHub CI run these for you. However, this would require you to rebase and amend your commits if CI fails, and it may result in slower feedback. 79 | 80 | 6. **Commit your changes** following the [Commit Guidelines](#commit-guidelines) 81 | ```bash 82 | git commit -s 83 | ``` 84 | 85 | 7. **Push to your fork**: 86 | ``` 87 | git push your-fork-name your-branch-name 88 | ``` 89 | 90 | 8. [**Create a Pull Request**](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request) following the [Pull Request Guidelines](#pull-request-guidelines) 91 | 92 | ## Commit Guidelines 93 | 94 | Your commit messages should be descriptive enough to explain **what** the commit changes and **why** it changes it. Additionally, succinctly describing **how** it changes is welcomed if convenient. You should leave deep discussions and the overarching context for the PR description. Commit messages should: 95 | 96 | - Clearly state what the commit does. 97 | - Follow the Conventional Commits style. 98 | - Briefly explain why the change was necessary. 99 | - If plausible, briefly explain how the change was done. 100 | 101 | Commit contents, in other words, the changes the commit introduce should: 102 | - Be **focused** and **atomic**, i.e, one logical change per commit. For example, if your changes include both bug fixes and feature additions, separate those into two or more commits. The point is that each commit should make an easily understood change without needing other commits to explain its existence. 103 | - Go for the simplest implementation possible. 104 | - Not destabilize the compilation, so `cargo build` shouldn't fail. 105 | - Not fail in CI jobs. 106 | 107 | ### Use Conventional Commits 108 | 109 | `patch-hub` follows the use of [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) wherein you indicate the type of change you're making at the start with a label, followed by a scope (optional, since the project is still small), and then the commit message after a colon. 110 | 111 | Example Commits: 112 | - `feat: allow provided config object to extend other configs` 113 | - `docs: correct spelling of CHANGELOG` 114 | 115 | Common prefixes: 116 | - `feat`: A new feature. 117 | - `fix`: A bug fix. 118 | - `ui`: User interface changes 119 | - `ci`: GitHub Actions changes 120 | - `docs`: Documentation updates 121 | - `refactor`: Code that neither fixes a bug nor adds a feature. 122 | - `style`: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc). 123 | 124 | ### Sign your work - the Developer's Certificate of Origin 125 | 126 | `patch-hub` adopts the [Developer's Certificate of Origin](#developers-certificate-of-origin) practice from the Linux kernel. All commits must include the following line at the bottom to certify that you wrote it or otherwise have the right to pass it on as an open-source patch. 127 | ``` 128 | Signed-off-by: Your Name 129 | ``` 130 | 131 | This line can be automatically added with the command `git commit -s`. Additionally, to make the review process more efficient, maintainers may make trivial changes to your commits instead of asking for changes after a review. It is important to note that: 132 | 133 | 1. The authorship is maintained, i.e., the credits for the commit will still go to the original author. 134 | 2. Any such edits made will be catalogued at the end of the original commit message under `[Maintainer edits]`. 135 | 136 | #### Developer's Certificate of Origin 137 | ``` 138 | Developer’s Certificate of Origin 1.1 139 | 140 | By making a contribution to this project, I certify that: 141 | 142 | a. The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or 143 | 144 | b. The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or 145 | 146 | c. The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it. 147 | 148 | d. I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. 149 | ``` 150 | 151 | ## Pull Request Guidelines 152 | 153 | PRs should provide the big picture: 154 | 155 | - What the PR does, i.e., the overarching context of all commits 156 | - Why the change was necessary (including any revelant context) 157 | - How it was implemented (if it's non-trivial) 158 | - Potential alternatives 159 | - Any follow-ups or future considerations 160 | 161 | Your PR will be merged once it: 162 | - Passes all automated checks 163 | - Receives approval from at least one maintainer 164 | - Meets the project's quality standards 165 | - Aligns with the project's goals and roadmap 166 | 167 | **Note**: PRs should always be opened using the branch `unstable` as a base. 168 | 169 | ## Issue Reporting 170 | 171 | Use the preconfigured templates on GitHub to report issues and request features. If none of these fit your issue, you can use the "Blank" option. 172 | 173 | ## License 174 | 175 | By contributing to `patch-hub`, you agree that your contributions will be licensed under the project's license (GPL-2.0, the same as kworkflow). 176 | 177 | --- 178 | 179 | Thank you for contributing to `patch-hub`! Your efforts help improve tools for the Linux kernel development community. 180 | -------------------------------------------------------------------------------- /src/app/logging.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fmt::Display, 3 | fs::{self, File, OpenOptions}, 4 | io::Write, 5 | }; 6 | 7 | use chrono::Local; 8 | 9 | use crate::app::config::Config; 10 | 11 | pub mod garbage_collector; 12 | pub mod log_on_error; 13 | 14 | const LATEST_LOG_FILENAME: &str = "latest.log"; 15 | 16 | static mut LOG_BUFFER: Logger = Logger { 17 | log_file: None, 18 | log_filepath: None, 19 | latest_log_file: None, 20 | latest_log_filepath: None, 21 | logs_to_print: Vec::new(), 22 | print_level: LogLevel::Warning, 23 | }; 24 | 25 | /// Describes the log level of a message 26 | /// 27 | /// This is used to determine the severity of a log message so the logger handles it accordingly to the verbosity level. 28 | /// 29 | /// The levels severity are: `Info` < `Warning` < `Error` 30 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 31 | #[allow(dead_code)] 32 | pub enum LogLevel { 33 | Info, 34 | Warning, 35 | Error, 36 | } 37 | 38 | #[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] 39 | pub struct LogMessage { 40 | level: LogLevel, 41 | message: String, 42 | } 43 | 44 | /// The Logger singleton that manages logging to [`stderr`] (log buffer) and a log file. 45 | /// This is safe to use only in single-threaded scenarios. The messages are written to the log file immediatly, 46 | /// but the messages to the `stderr` are written only after the TUI is closed, so they are kept in memory. 47 | /// 48 | /// The logger also has a log level that can be set to filter the messages that are written to the log file. 49 | /// Only messages with a level equal or higher than the log level are written to the log file. 50 | /// 51 | /// The expected flow is: 52 | /// - Initialize the log file with [`init_log_file`] 53 | /// - Write to the log file with [`info`], [`warn`] or [`error`] 54 | /// - Flush the log buffer to the stderr and close the log file with [`flush`] 55 | /// 56 | /// The log file is created in the logs_path defined in the [`Config`] struct 57 | /// 58 | /// [`Config`]: super::config::Config 59 | /// [`init_log_file`]: Logger::init_log_file 60 | /// [`info`]: Logger::info 61 | /// [`warn`]: Logger::warn 62 | /// [`error`]: Logger::error 63 | /// [`flush`]: Logger::flush 64 | /// [`stderr`]: std::io::stderr 65 | #[derive(Debug)] 66 | pub struct Logger { 67 | log_file: Option, 68 | log_filepath: Option, 69 | latest_log_file: Option, 70 | latest_log_filepath: Option, 71 | logs_to_print: Vec, 72 | print_level: LogLevel, // TODO: Add a log level configuration 73 | } 74 | 75 | impl Logger { 76 | /// Private method to get access to the Logger singleton 77 | /// 78 | /// This function makes use of unsafe code to access a static mut. Also, it's `inline` so won't have any overhead 79 | /// 80 | /// # Safety 81 | /// 82 | /// It's safe to use in single-threaded scenarios only 83 | /// 84 | /// # Examples 85 | /// ```rust norun 86 | /// // Get the logger singleton 87 | /// Logger::init_log_file(&config); // Initialize the log file 88 | /// Logger::info("This is an info log message"); // Write a message to the log file 89 | /// ``` 90 | #[inline] 91 | fn get_logger() -> &'static mut Logger { 92 | #[allow(static_mut_refs)] 93 | unsafe { 94 | &mut LOG_BUFFER 95 | } 96 | } 97 | 98 | /// Write the string `msg` to the logs to print buffer and the log file 99 | /// 100 | /// # Panics 101 | /// 102 | /// If the log file is not initialized 103 | /// 104 | /// # Examples 105 | /// ```rust norun 106 | /// // Make sure to initialize the log file before writing to it 107 | /// Logger::init_log_file(&config); 108 | /// // Get the logger singleton and write a message to the log file 109 | /// Logger::get_logger().log(LogLevel::Info, "This is a log message"); 110 | /// ``` 111 | fn log(&mut self, level: LogLevel, message: M) { 112 | let current_datetime = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); 113 | let message = format!("[{}] {}", current_datetime, message); 114 | 115 | let log = LogMessage { level, message }; 116 | 117 | let file = self.log_file 118 | .as_mut() 119 | .expect("Log file not initialized, make sure to call Logger::init_log_file() before writing to the log file"); 120 | writeln!(file, "{log}").expect("Failed to write to log file"); 121 | 122 | let latest_log_file = self.latest_log_file 123 | .as_mut() 124 | .expect("Latest log file not initialized, make sure to call Logger::init_log_file() before writing to the log file"); 125 | writeln!(latest_log_file, "{log}").expect("Failed to write to real time log file"); 126 | 127 | if self.print_level <= level { 128 | // Only save logs to print w/ level equal or higher than the filter log level 129 | self.logs_to_print.push(log); 130 | } 131 | } 132 | 133 | /// Write an info message to the log 134 | /// 135 | /// # Panics 136 | /// 137 | /// If the log file is not initialized 138 | /// 139 | /// # Safety 140 | /// 141 | /// It's safe to use in single-threaded scenarios only 142 | /// 143 | /// # Examples 144 | /// 145 | /// ```rust norun 146 | /// 147 | /// // Make sure to initialize the log file before writing to it 148 | /// Logger::init_log_file(&config); 149 | /// Logger::info("This is an info message"); // [INFO] [2024-09-11 14:59:00] This is an info message 150 | /// ``` 151 | #[inline] 152 | #[allow(dead_code)] 153 | pub fn info(msg: M) { 154 | Logger::get_logger().log(LogLevel::Info, msg); 155 | } 156 | 157 | /// Write a warn message to the log 158 | /// 159 | /// # Panics 160 | /// 161 | /// If the log file is not initialized 162 | /// 163 | /// # Safety 164 | /// 165 | /// It's safe to use in single-threaded scenarios only 166 | /// 167 | /// # Examples 168 | /// 169 | /// ```rust norun 170 | /// 171 | /// // Make sure to initialize the log file before writing to it 172 | /// Logger::init_log_file(&config); 173 | /// Logger::warn("This is a warning"); // [WARN] [2024-09-11 14:59:00] This is a warning 174 | /// ``` 175 | #[inline] 176 | #[allow(dead_code)] 177 | pub fn warn(msg: M) { 178 | Logger::get_logger().log(LogLevel::Warning, msg); 179 | } 180 | 181 | /// Write an error message to the log 182 | /// 183 | /// # Panics 184 | /// 185 | /// If the log file is not initialized 186 | /// 187 | /// # Safety 188 | /// 189 | /// It's safe to use in single-threaded scenarios only 190 | /// 191 | /// # Examples 192 | /// 193 | /// ```rust norun 194 | /// 195 | /// // Make sure to initialize the log file before writing to it 196 | /// Logger::init_log_file(&config); 197 | /// Logger::error("This is an error message"); // [ERROR] [2024-09-11 14:59:00] This is an error message 198 | /// ``` 199 | #[inline] 200 | #[allow(dead_code)] 201 | pub fn error(msg: M) { 202 | Logger::get_logger().log(LogLevel::Error, msg); 203 | } 204 | 205 | /// Flush the log buffer to stderr and closes the log file. 206 | /// It's intended to be called only once when patch-hub is finishing. 207 | /// 208 | /// # Panics 209 | /// 210 | /// If called before the log file is initialized or if called twice 211 | /// 212 | /// # Examples 213 | /// ```rust norun 214 | /// // Make sure to initialize the log file before writing to it 215 | /// Logger::init_log_file(&config); 216 | /// 217 | /// // Flush before finishing the application 218 | /// Logger::flush(); 219 | /// // Any further attempt to use the logger will panic, unless it's reinitialized 220 | /// ``` 221 | pub fn flush() { 222 | let logger = Logger::get_logger(); 223 | for entry in &logger.logs_to_print { 224 | eprintln!("{}", entry); 225 | } 226 | 227 | if let Some(f) = &logger.log_filepath { 228 | eprintln!("Check the full log file: {}", f); 229 | } 230 | } 231 | 232 | /// Initialize the log file. 233 | /// 234 | /// This function must be called before any other operation with the logging system 235 | /// 236 | /// # Panics 237 | /// 238 | /// If it fails to create the log file 239 | /// 240 | /// # Examples 241 | /// ```rust norun 242 | /// // Once you get the config struct... 243 | /// let config = Config::build(); 244 | /// // ... initialize the log file 245 | /// Logger::init_log_file(&config); 246 | /// ``` 247 | pub fn init_log_file(config: &Config) -> Result<(), std::io::Error> { 248 | let logger = Logger::get_logger(); 249 | 250 | let logs_path = config.logs_path(); 251 | fs::create_dir_all(logs_path)?; 252 | 253 | if logger.latest_log_file.is_none() { 254 | let latest_log_filename = LATEST_LOG_FILENAME.to_string(); 255 | let latest_log_filepath = format!("{}/{}", logs_path, latest_log_filename); 256 | 257 | File::create(&latest_log_filepath)?; 258 | 259 | let log_file = OpenOptions::new() 260 | .create(true) 261 | .append(true) 262 | .open(&latest_log_filepath)?; 263 | 264 | logger.latest_log_file = Some(log_file); 265 | logger.latest_log_filepath = Some(latest_log_filepath); 266 | } 267 | 268 | if logger.log_file.is_none() { 269 | let log_filename = format!( 270 | "patch-hub_{}.log", 271 | chrono::Local::now().format("%Y%m%d-%H%M%S") 272 | ); 273 | let log_filepath = format!("{}/{}", logs_path, log_filename); 274 | 275 | let log_file = OpenOptions::new() 276 | .create(true) 277 | .append(true) 278 | .open(&log_filepath)?; 279 | 280 | logger.log_file = Some(log_file); 281 | logger.log_filepath = Some(log_filepath); 282 | } 283 | 284 | Ok(()) 285 | } 286 | } 287 | 288 | impl Display for LogMessage { 289 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 290 | write!(f, "[{}] {}", self.level, self.message) 291 | } 292 | } 293 | 294 | impl Display for LogLevel { 295 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 296 | match self { 297 | LogLevel::Info => write!(f, "INFO"), 298 | LogLevel::Warning => write!(f, "WARN"), 299 | LogLevel::Error => write!(f, "ERROR"), 300 | } 301 | } 302 | } 303 | -------------------------------------------------------------------------------- /src/ui/details_actions.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | layout::{Constraint, Direction, Layout, Rect}, 3 | style::{Color, Modifier, Style}, 4 | text::{Line, Span}, 5 | widgets::{Block, Borders, Padding, Paragraph, Wrap}, 6 | Frame, 7 | }; 8 | 9 | use crate::app::{ 10 | screens::details_actions::{DetailsActions, PatchsetAction}, 11 | App, 12 | }; 13 | 14 | /// Returns a `Line` type that represents a line containing stats about reply 15 | /// trailers. It currently considers the _Reviewed-by_, _Tested-by_, and 16 | /// _Acked-by_ trailers and colors them depending if they are 0 or not. Example 17 | /// of line returned: 18 | /// 19 | /// _**Reviewed-by: 1 | Tested-by: 0 | Acked-by: 2**_ 20 | fn review_trailers_details(details_actions: &DetailsActions) -> Line<'static> { 21 | let i = details_actions.preview_index; 22 | 23 | let resolve_color = |n_trailers: usize| -> Style { 24 | if n_trailers == 0 { 25 | Style::default().fg(Color::White) 26 | } else { 27 | Style::default().fg(Color::Green) 28 | } 29 | }; 30 | 31 | Line::from(vec![ 32 | Span::styled("Reviewed-by: ", Style::default().fg(Color::Cyan)), 33 | Span::styled( 34 | details_actions.reviewed_by[i].len().to_string(), 35 | resolve_color(details_actions.reviewed_by[i].len()), 36 | ), 37 | Span::styled(" | Tested-by: ", Style::default().fg(Color::Cyan)), 38 | Span::styled( 39 | details_actions.tested_by[i].len().to_string(), 40 | resolve_color(details_actions.tested_by[i].len()), 41 | ), 42 | Span::styled(" | Acked-by: ", Style::default().fg(Color::Cyan)), 43 | Span::styled( 44 | details_actions.acked_by[i].len().to_string(), 45 | resolve_color(details_actions.acked_by[i].len()), 46 | ), 47 | ]) 48 | } 49 | 50 | fn render_details_and_actions(f: &mut Frame, app: &App, details_chunk: Rect, actions_chunk: Rect) { 51 | let patchset_details_and_actions = app.details_actions.as_ref().unwrap(); 52 | 53 | let mut staged_to_reply = String::new(); 54 | if let Some(true) = patchset_details_and_actions 55 | .patchset_actions 56 | .get(&PatchsetAction::ReplyWithReviewedBy) 57 | { 58 | staged_to_reply.push('('); 59 | let number_offset = if patchset_details_and_actions.has_cover_letter { 60 | 0 61 | } else { 62 | 1 63 | }; 64 | let patches_to_reply_numbers: Vec = patchset_details_and_actions 65 | .patches_to_reply 66 | .iter() 67 | .enumerate() 68 | .filter_map(|(i, &val)| if val { Some(i + number_offset) } else { None }) 69 | .collect(); 70 | for number in patches_to_reply_numbers { 71 | staged_to_reply.push_str(&format!("{number}, ")); 72 | } 73 | staged_to_reply.pop(); 74 | staged_to_reply = format!("{})", &staged_to_reply[..staged_to_reply.len() - 1]); 75 | } 76 | 77 | let patchset_details = &patchset_details_and_actions.representative_patch; 78 | let mut patchset_details = vec![ 79 | Line::from(vec![ 80 | Span::styled(r#" Title: "#, Style::default().fg(Color::Cyan)), 81 | Span::styled( 82 | patchset_details.title().to_string(), 83 | Style::default().fg(Color::White), 84 | ), 85 | ]), 86 | Line::from(vec![ 87 | Span::styled("Author: ", Style::default().fg(Color::Cyan)), 88 | Span::styled( 89 | patchset_details.author().name.to_string(), 90 | Style::default().fg(Color::White), 91 | ), 92 | ]), 93 | Line::from(vec![ 94 | Span::styled("Version: ", Style::default().fg(Color::Cyan)), 95 | Span::styled( 96 | format!("{}", patchset_details.version()), 97 | Style::default().fg(Color::White), 98 | ), 99 | ]), 100 | Line::from(vec![ 101 | Span::styled("Patch count: ", Style::default().fg(Color::Cyan)), 102 | Span::styled( 103 | format!("{}", patchset_details.total_in_series()), 104 | Style::default().fg(Color::White), 105 | ), 106 | ]), 107 | Line::from(vec![ 108 | Span::styled("Last updated: ", Style::default().fg(Color::Cyan)), 109 | Span::styled( 110 | patchset_details.updated().to_string(), 111 | Style::default().fg(Color::White), 112 | ), 113 | ]), 114 | review_trailers_details(patchset_details_and_actions), 115 | ]; 116 | if !staged_to_reply.is_empty() { 117 | patchset_details.push(Line::from(vec![ 118 | Span::styled("Staged to reply: ", Style::default().fg(Color::Cyan)), 119 | Span::styled(staged_to_reply, Style::default().fg(Color::White)), 120 | ])); 121 | } 122 | 123 | let patchset_details = Paragraph::new(patchset_details) 124 | .block( 125 | Block::default() 126 | .borders(Borders::ALL) 127 | .border_type(ratatui::widgets::BorderType::Double) 128 | .title(Line::styled(" Details ", Style::default().fg(Color::Green)).left_aligned()) 129 | .padding(Padding::vertical(1)), 130 | ) 131 | .left_aligned() 132 | .wrap(Wrap { trim: true }); 133 | 134 | f.render_widget(patchset_details, details_chunk); 135 | 136 | let patchset_actions = &patchset_details_and_actions.patchset_actions; 137 | // TODO: Create a function to produce new action lines 138 | let patchset_actions = vec![ 139 | Line::from(vec![ 140 | if *patchset_actions.get(&PatchsetAction::Bookmark).unwrap() { 141 | Span::styled("[x] ", Style::default().fg(Color::Green)) 142 | } else { 143 | Span::styled("[ ] ", Style::default().fg(Color::Cyan)) 144 | }, 145 | Span::styled( 146 | "b", 147 | Style::default() 148 | .fg(Color::Cyan) 149 | .add_modifier(Modifier::UNDERLINED) 150 | .add_modifier(Modifier::BOLD), 151 | ), 152 | Span::styled("ookmark", Style::default().fg(Color::Cyan)), 153 | ]), 154 | Line::from(vec![ 155 | if *patchset_actions.get(&PatchsetAction::Apply).unwrap() { 156 | Span::styled("[x] ", Style::default().fg(Color::Green)) 157 | } else { 158 | Span::styled("[ ] ", Style::default().fg(Color::Cyan)) 159 | }, 160 | Span::styled( 161 | "a", 162 | Style::default() 163 | .fg(Color::Cyan) 164 | .add_modifier(Modifier::UNDERLINED) 165 | .add_modifier(Modifier::BOLD), 166 | ), 167 | Span::styled("pply", Style::default().fg(Color::Cyan)), 168 | ]), 169 | Line::from(vec![ 170 | if *patchset_details_and_actions 171 | .patches_to_reply 172 | .get(patchset_details_and_actions.preview_index) 173 | .unwrap() 174 | { 175 | Span::styled("[x] ", Style::default().fg(Color::Green)) 176 | } else { 177 | Span::styled("[ ] ", Style::default().fg(Color::Cyan)) 178 | }, 179 | Span::styled( 180 | "r", 181 | Style::default() 182 | .fg(Color::Cyan) 183 | .add_modifier(Modifier::UNDERLINED) 184 | .add_modifier(Modifier::BOLD), 185 | ), 186 | Span::styled("eviewed-by", Style::default().fg(Color::Cyan)), 187 | ]), 188 | ]; 189 | let patchset_actions = Paragraph::new(patchset_actions) 190 | .block( 191 | Block::default() 192 | .borders(Borders::ALL) 193 | .border_type(ratatui::widgets::BorderType::Double) 194 | .title(Line::styled(" Actions ", Style::default().fg(Color::Green)).left_aligned()) 195 | .padding(Padding::vertical(1)), 196 | ) 197 | .centered(); 198 | 199 | f.render_widget(patchset_actions, actions_chunk); 200 | } 201 | 202 | fn render_preview(f: &mut Frame, app: &App, chunk: Rect) { 203 | let patchset_details_and_actions = app.details_actions.as_ref().unwrap(); 204 | 205 | let preview_index = patchset_details_and_actions.preview_index; 206 | 207 | let representative_patch_message_id = &patchset_details_and_actions 208 | .representative_patch 209 | .message_id() 210 | .href; 211 | let mut preview_title = String::from(" Preview "); 212 | if matches!( 213 | app.reviewed_patchsets.get(representative_patch_message_id), 214 | Some(successful_indexes) if successful_indexes.contains(&preview_index) 215 | ) { 216 | preview_title = " Preview [REVIEWED-BY] ".to_string(); 217 | } else if *patchset_details_and_actions 218 | .patches_to_reply 219 | .get(preview_index) 220 | .unwrap() 221 | { 222 | preview_title = " Preview [REVIEWED-BY]* ".to_string(); 223 | }; 224 | 225 | let preview_offset = patchset_details_and_actions.preview_scroll_offset; 226 | let preview_pan = patchset_details_and_actions.preview_pan; 227 | let patch_preview = patchset_details_and_actions.patches_preview[preview_index].clone(); 228 | 229 | let patch_preview = Paragraph::new(patch_preview) 230 | .block( 231 | Block::default() 232 | .borders(Borders::ALL) 233 | .border_type(ratatui::widgets::BorderType::Double) 234 | .title( 235 | Line::styled(preview_title, Style::default().fg(Color::Green)).left_aligned(), 236 | ) 237 | .padding(Padding::vertical(1)), 238 | ) 239 | .left_aligned() 240 | .scroll((preview_offset as u16, preview_pan as u16)); 241 | 242 | f.render_widget(patch_preview, chunk); 243 | } 244 | 245 | pub fn render_main(f: &mut Frame, app: &App, chunk: Rect) { 246 | let patchset_details_and_actions = app.details_actions.as_ref().unwrap(); 247 | 248 | if patchset_details_and_actions.preview_fullscreen { 249 | render_preview(f, app, chunk); 250 | } else { 251 | let chunks = Layout::default() 252 | .direction(Direction::Horizontal) 253 | .constraints([Constraint::Percentage(30), Constraint::Percentage(70)]) 254 | .split(chunk); 255 | 256 | let details_and_actions_chunks = Layout::default() 257 | .direction(Direction::Vertical) 258 | .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) 259 | .split(chunks[0]); 260 | 261 | render_details_and_actions( 262 | f, 263 | app, 264 | details_and_actions_chunks[0], 265 | details_and_actions_chunks[1], 266 | ); 267 | render_preview(f, app, chunks[1]); 268 | } 269 | } 270 | 271 | pub fn mode_footer_text() -> Vec> { 272 | vec![Span::styled( 273 | "Patchset Details and Actions", 274 | Style::default().fg(Color::Green), 275 | )] 276 | } 277 | 278 | pub fn keys_hint() -> Span<'static> { 279 | Span::styled( 280 | "(ESC / q) to return | (ENTER) run actions | (?) help", 281 | Style::default().fg(Color::Red), 282 | ) 283 | } 284 | --------------------------------------------------------------------------------