lazy-github 0.6.0__tar.gz → 0.6.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. {lazy_github-0.6.0 → lazy_github-0.6.2}/PKG-INFO +1 -1
  2. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/cli.py +15 -3
  3. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/bindings.py +7 -0
  4. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/github/backends/cli.py +1 -1
  5. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/github/issues.py +8 -0
  6. lazy_github-0.6.2/lazy_github/lib/github/reactions.py +41 -0
  7. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/messages.py +11 -3
  8. lazy_github-0.6.2/lazy_github/lib/pr_drafts.py +65 -0
  9. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/models/github.py +42 -1
  10. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/screens/create_or_edit_pull_request.py +91 -0
  11. lazy_github-0.6.2/lazy_github/ui/screens/lookup_issue.py +89 -0
  12. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/screens/primary.py +17 -7
  13. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/widgets/conversations.py +89 -10
  14. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/widgets/issues.py +13 -1
  15. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/widgets/pull_requests.py +109 -29
  16. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/widgets/repositories.py +3 -0
  17. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/widgets/workflows.py +9 -2
  18. lazy_github-0.6.2/lazy_github/version.py +1 -0
  19. lazy_github-0.6.2/lint.sh +6 -0
  20. lazy_github-0.6.0/lazy_github/version.py +0 -1
  21. lazy_github-0.6.0/lint.sh +0 -6
  22. {lazy_github-0.6.0 → lazy_github-0.6.2}/.devcontainer/devcontainer.json +0 -0
  23. {lazy_github-0.6.0 → lazy_github-0.6.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  24. {lazy_github-0.6.0 → lazy_github-0.6.2}/.github/workflows/code-checks.yaml +0 -0
  25. {lazy_github-0.6.0 → lazy_github-0.6.2}/.github/workflows/publish.yaml +0 -0
  26. {lazy_github-0.6.0 → lazy_github-0.6.2}/.gitignore +0 -0
  27. {lazy_github-0.6.0 → lazy_github-0.6.2}/.pre-commit-config.yaml +0 -0
  28. {lazy_github-0.6.0 → lazy_github-0.6.2}/.quicklinks/README.md +0 -0
  29. {lazy_github-0.6.0 → lazy_github-0.6.2}/.quicklinks/config.lua +0 -0
  30. {lazy_github-0.6.0 → lazy_github-0.6.2}/.quicklinks/queries/python/patterns/textual_imports.scm +0 -0
  31. {lazy_github-0.6.0 → lazy_github-0.6.2}/LICENSE +0 -0
  32. {lazy_github-0.6.0 → lazy_github-0.6.2}/README.md +0 -0
  33. {lazy_github-0.6.0 → lazy_github-0.6.2}/flake.lock +0 -0
  34. {lazy_github-0.6.0 → lazy_github-0.6.2}/flake.nix +0 -0
  35. {lazy_github-0.6.0 → lazy_github-0.6.2}/gh-lazy +0 -0
  36. {lazy_github-0.6.0 → lazy_github-0.6.2}/images/lazy-github-conversation-ui.svg +0 -0
  37. {lazy_github-0.6.0 → lazy_github-0.6.2}/images/lazy-github-settings-ui.png +0 -0
  38. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/__main__.py +0 -0
  39. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/cache.py +0 -0
  40. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/config.py +0 -0
  41. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/constants.py +0 -0
  42. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/context.py +0 -0
  43. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/debug.py +0 -0
  44. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/diff_parser.py +0 -0
  45. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/git_cli.py +0 -0
  46. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/github/auth.py +0 -0
  47. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/github/backends/hishel.py +0 -0
  48. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/github/backends/protocol.py +0 -0
  49. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/github/branches.py +0 -0
  50. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/github/checks.py +0 -0
  51. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/github/client.py +0 -0
  52. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/github/notifications.py +0 -0
  53. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/github/pull_requests.py +0 -0
  54. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/github/repositories.py +0 -0
  55. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/github/users.py +0 -0
  56. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/github/workflows.py +0 -0
  57. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/lib/logging.py +0 -0
  58. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/app.py +0 -0
  59. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/screens/auth.py +0 -0
  60. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/screens/confirm.py +0 -0
  61. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/screens/debug.py +0 -0
  62. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/screens/edit_issue.py +0 -0
  63. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/screens/lookup_pull_request.py +0 -0
  64. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/screens/lookup_repository.py +0 -0
  65. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/screens/new_comment.py +0 -0
  66. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/screens/new_issue.py +0 -0
  67. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/screens/notifications.py +0 -0
  68. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/screens/settings.py +0 -0
  69. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/screens/trigger_workflow.py +0 -0
  70. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/widgets/command_log.py +0 -0
  71. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/widgets/common.py +0 -0
  72. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/widgets/diff_viewer.py +0 -0
  73. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/widgets/info.py +0 -0
  74. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/widgets/split_diff_viewer.py +0 -0
  75. {lazy_github-0.6.0 → lazy_github-0.6.2}/lazy_github/ui/widgets/workflow_run_details.py +0 -0
  76. {lazy_github-0.6.0 → lazy_github-0.6.2}/pyproject.toml +0 -0
  77. {lazy_github-0.6.0 → lazy_github-0.6.2}/start.sh +0 -0
  78. {lazy_github-0.6.0 → lazy_github-0.6.2}/tests/__init__.py +0 -0
  79. {lazy_github-0.6.0 → lazy_github-0.6.2}/tests/test_cache.py +0 -0
  80. {lazy_github-0.6.0 → lazy_github-0.6.2}/tests/test_config.py +0 -0
  81. {lazy_github-0.6.0 → lazy_github-0.6.2}/tests/test_settings_ui.py +0 -0
  82. {lazy_github-0.6.0 → lazy_github-0.6.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lazy-github
3
- Version: 0.6.0
3
+ Version: 0.6.2
4
4
  Summary: A terminal UI for interacting with Github
5
5
  Author-email: "Chris (Gizmo)" <gizmo385@users.noreply.github.com>
6
6
  Maintainer-email: "Chris (Gizmo)" <gizmo385@users.noreply.github.com>
@@ -55,10 +55,14 @@ def clear_auth():
55
55
 
56
56
 
57
57
  @cli.command
58
- def clear_config():
58
+ @click.option("--no-confirm", is_flag=True, default=False, help="Don't ask for confirmation")
59
+ def clear_config(no_confirm: bool):
59
60
  """Reset the user's settings"""
60
- _CONFIG_FILE_LOCATION.unlink(missing_ok=True)
61
- print("Your settings have been cleared")
61
+ if no_confirm or click.confirm("Confirm deletion your LazyGithub settings"):
62
+ _CONFIG_FILE_LOCATION.unlink(missing_ok=True)
63
+ print("Your settings have been cleared")
64
+ else:
65
+ print("Canceling")
62
66
 
63
67
 
64
68
  @cli.command
@@ -80,3 +84,11 @@ def clear_cache(no_confirm: bool):
80
84
  def debug():
81
85
  """Outputs LazyGithub debug info"""
82
86
  print(collect_debug_info())
87
+
88
+
89
+ @cli.command
90
+ def version():
91
+ """Prints the current version"""
92
+ from lazy_github.version import VERSION
93
+
94
+ print(f"LazyGithub version {VERSION}")
@@ -38,6 +38,13 @@ class LazyGithubBindings:
38
38
  "I", "open_issue", "New Issue", id="issue.new", tooltip="Open a new issue in the current repository"
39
39
  )
40
40
  EDIT_ISSUE = Binding("E", "edit_issue", "Edit Issue", id="issue.edit", tooltip="Edit the selected issue")
41
+ LOOKUP_ISSUE = Binding(
42
+ "O",
43
+ "lookup_issue",
44
+ "Lookup Issue",
45
+ id="issue.lookup",
46
+ tooltip="Lookup an issue in the current repository",
47
+ )
41
48
 
42
49
  # Pull request actions
43
50
  OPEN_PULL_REQUEST = Binding(
@@ -101,7 +101,7 @@ def _create_request_body_tempfile(body: bytes) -> tempfile._TemporaryFileWrapper
101
101
  _TEMPORARY_JSON_BODY_DIRECTORY.mkdir(parents=True, exist_ok=True)
102
102
  if sys.version_info.minor > 11:
103
103
  # delete_on_close parameter added in Python 3.12
104
- temp = tempfile.NamedTemporaryFile(delete=False, delete_on_close=False, dir=_TEMPORARY_JSON_BODY_DIRECTORY) # type: ignore[call-arg]
104
+ temp = tempfile.NamedTemporaryFile(delete=False, delete_on_close=False, dir=_TEMPORARY_JSON_BODY_DIRECTORY)
105
105
  else:
106
106
  temp = tempfile.NamedTemporaryFile(delete=False, dir=_TEMPORARY_JSON_BODY_DIRECTORY)
107
107
  temp.write(body)
@@ -7,6 +7,14 @@ from lazy_github.models.github import Issue, IssueComment, PartialPullRequest, R
7
7
  DEFAULT_PAGE_SIZE = 30
8
8
 
9
9
 
10
+ async def get_issue_by_number(repo: Repository, issue_number: int) -> Issue:
11
+ """Looks up a single issue by number in the given repository"""
12
+ url = f"/repos/{repo.owner.login}/{repo.name}/issues/{issue_number}"
13
+ response = await LazyGithubContext.client.get(url, headers=github_headers())
14
+ response.raise_for_status()
15
+ return Issue(**response.json(), repo=repo)
16
+
17
+
10
18
  class UpdateIssuePayload(TypedDict):
11
19
  title: str | None
12
20
  body: str | None
@@ -0,0 +1,41 @@
1
+ from typing import Any
2
+
3
+ from lazy_github.lib.context import LazyGithubContext, github_headers
4
+ from lazy_github.models.github import Issue, IssueComment, Reaction, ReactionSet, ReactionType, Repository, User
5
+
6
+
7
+ def _build_reaction_set(response: Any) -> ReactionSet:
8
+ response.raise_for_status()
9
+ reaction_users: dict[ReactionType, list[User]] = {}
10
+ reaction_counts: dict[ReactionType, int] = {}
11
+
12
+ for raw_reaction in response.json():
13
+ reaction_type = ReactionType.from_github(raw_reaction["content"])
14
+ reaction_users.setdefault(reaction_type, [])
15
+ reaction_counts.setdefault(reaction_type, 0)
16
+
17
+ user = User(**raw_reaction["user"])
18
+ reaction_users[reaction_type].append(user)
19
+ reaction_counts[reaction_type] += 1
20
+
21
+ return ReactionSet(reaction_users=reaction_users, reaction_counts=reaction_counts)
22
+
23
+
24
+ async def list_reactions_on_comment(repo: Repository, comment: IssueComment) -> ReactionSet:
25
+ url = f"/repos/{repo.full_name}/issues/comments/{comment.id}/reactions"
26
+ response = await LazyGithubContext.client.get(url, headers=github_headers())
27
+ return _build_reaction_set(response)
28
+
29
+
30
+ async def add_reaction_on_comment(repo: Repository, comment: IssueComment, reaction: ReactionType) -> Reaction:
31
+ url = f"/repos/{repo.full_name}/issues/comments/{comment.id}/reactions"
32
+ body = {"content": reaction.name.lower()}
33
+ response = await LazyGithubContext.client.post(url, headers=github_headers(), json=body)
34
+ response.raise_for_status()
35
+ return Reaction(**response.json())
36
+
37
+
38
+ async def list_reactions_on_issue(repo: Repository, issue: Issue) -> ReactionSet:
39
+ url = f"/repos/{repo.full_name}/issues/{issue.number}/reactions"
40
+ response = await LazyGithubContext.client.get(url, headers=github_headers())
41
+ return _build_reaction_set(response)
@@ -9,18 +9,26 @@ from lazy_github.models.github import (
9
9
  IssueComment,
10
10
  Notification,
11
11
  PartialPullRequest,
12
+ ReactionSet,
12
13
  Repository,
13
14
  Review,
14
15
  WorkflowRun,
15
16
  )
16
17
 
17
18
 
18
- class ReviewsLoaded(Message):
19
- """A message sent when reviews for a pull request have been loaded"""
19
+ class ReviewsAndCommentsLoaded(Message):
20
+ """A message sent when reviews and comments for a pull request have been loaded"""
20
21
 
21
- def __init__(self, reviews: list[Review]) -> None:
22
+ def __init__(self, reviews: list[Review], comments: list[IssueComment]) -> None:
22
23
  super().__init__()
23
24
  self.reviews = reviews
25
+ self.comments = comments
26
+
27
+
28
+ class CommentReactionsLoaded(Message):
29
+ def __init__(self, reactions: dict[int, ReactionSet]) -> None:
30
+ super().__init__()
31
+ self.reactions = reactions
24
32
 
25
33
 
26
34
  class RepoSelected(Message):
@@ -0,0 +1,65 @@
1
+ import json
2
+ from dataclasses import asdict, dataclass
3
+ from pathlib import Path
4
+
5
+ from lazy_github.lib.context import LazyGithubContext
6
+ from lazy_github.lib.logging import lg
7
+
8
+
9
+ @dataclass
10
+ class PullRequestDraft:
11
+ repo_full_name: str
12
+ title: str
13
+ description: str
14
+ base_ref: str
15
+ head_ref: str
16
+ is_draft: bool
17
+ reviewers: list[str]
18
+
19
+
20
+ def get_draft_path(repo_full_name: str) -> Path:
21
+ """Returns the path to the draft file for a given repository."""
22
+ safe_name = repo_full_name.replace("/", "_")
23
+ return LazyGithubContext.config.cache.cache_directory / "pr_drafts" / f"{safe_name}.json"
24
+
25
+
26
+ def load_pr_draft(repo_full_name: str) -> PullRequestDraft | None:
27
+ """Load a PR draft for the given repository, returning None if not found or corrupt."""
28
+ draft_path = get_draft_path(repo_full_name)
29
+ if not draft_path.exists():
30
+ return None
31
+
32
+ try:
33
+ data = json.loads(draft_path.read_text())
34
+ return PullRequestDraft(**data)
35
+ except json.JSONDecodeError as e:
36
+ lg.warning(f"Failed to parse PR draft file '{draft_path}' as valid JSON: {e}. Ignoring draft.")
37
+ return None
38
+ except (TypeError, KeyError) as e:
39
+ lg.warning(f"PR draft file '{draft_path}' has invalid structure: {e}. Ignoring draft.")
40
+ return None
41
+ except Exception as e:
42
+ lg.warning(f"Unexpected error loading PR draft from '{draft_path}': {e}. Ignoring draft.")
43
+ return None
44
+
45
+
46
+ def save_pr_draft(draft: PullRequestDraft) -> None:
47
+ """Save a PR draft to disk."""
48
+ draft_path = get_draft_path(draft.repo_full_name)
49
+ try:
50
+ draft_path.parent.mkdir(parents=True, exist_ok=True)
51
+ draft_path.write_text(json.dumps(asdict(draft), indent=2))
52
+ lg.debug(f"Saved PR draft to {draft_path}")
53
+ except Exception as e:
54
+ lg.warning(f"Failed to save PR draft to '{draft_path}': {e}")
55
+
56
+
57
+ def clear_pr_draft(repo_full_name: str) -> None:
58
+ """Delete the draft file for the given repository."""
59
+ draft_path = get_draft_path(repo_full_name)
60
+ try:
61
+ if draft_path.exists():
62
+ draft_path.unlink()
63
+ lg.debug(f"Cleared PR draft at {draft_path}")
64
+ except Exception as e:
65
+ lg.warning(f"Failed to clear PR draft at '{draft_path}': {e}")
@@ -1,5 +1,5 @@
1
1
  from datetime import datetime
2
- from enum import StrEnum
2
+ from enum import Enum, StrEnum
3
3
 
4
4
  from pydantic import BaseModel, Field
5
5
 
@@ -302,3 +302,44 @@ class CheckStatus(BaseModel):
302
302
  class CombinedCheckStatus(BaseModel):
303
303
  state: CheckStatusState
304
304
  statuses: list[CheckStatus]
305
+
306
+
307
+ class ReactionType(Enum):
308
+ def __init__(self, value: str, emoji: str) -> None:
309
+ super().__init__()
310
+ self._value = value
311
+ self.emoji = emoji
312
+
313
+ @classmethod
314
+ def from_github(cls, github_content: str) -> "ReactionType":
315
+ try:
316
+ return cls[github_content.upper()]
317
+ except KeyError:
318
+ if github_content == "+1":
319
+ return ReactionType.THUMBS_UP
320
+ elif github_content == "-1":
321
+ return ReactionType.THUMBS_DOWN
322
+ else:
323
+ raise ValueError(f"Invalid reaction string: {github_content}")
324
+
325
+ THUMBS_UP = ("+1", "👍")
326
+ THUMBS_DOWN = ("-1", "👎")
327
+ LAUGH = ("laugh", "😄")
328
+ CONFUSED = ("confused", "😕")
329
+ HEART = ("heart", "❤️")
330
+ HOORAY = ("hooray", "🎉")
331
+ ROCKET = ("rocket", "🚀")
332
+ EYES = ("eyes", "👀")
333
+
334
+
335
+ class Reaction(BaseModel):
336
+ reaction_type: ReactionType
337
+ user: User
338
+
339
+
340
+ class ReactionSet(BaseModel):
341
+ reaction_users: dict[ReactionType, list[User]]
342
+ reaction_counts: dict[ReactionType, int]
343
+
344
+ def __bool__(self) -> bool:
345
+ return bool(self.reaction_counts) or bool(self.reaction_users)
@@ -5,6 +5,7 @@ from textual.app import ComposeResult
5
5
  from textual.containers import Horizontal, Vertical, VerticalScroll
6
6
  from textual.content import Content
7
7
  from textual.screen import ModalScreen
8
+ from textual.timer import Timer
8
9
  from textual.types import DuplicateID
9
10
  from textual.widgets import Button, Input, Label, Markdown, Rule, SelectionList, Switch, TextArea
10
11
  from textual.widgets.selection_list import Selection
@@ -27,6 +28,7 @@ from lazy_github.lib.github.repositories import get_collaborators
27
28
  from lazy_github.lib.github.users import get_user_by_username
28
29
  from lazy_github.lib.logging import lg
29
30
  from lazy_github.lib.messages import BranchesLoaded, PullRequestCreatedOrUpdated
31
+ from lazy_github.lib.pr_drafts import PullRequestDraft, clear_pr_draft, load_pr_draft, save_pr_draft
30
32
  from lazy_github.models.github import Branch, FullPullRequest, PartialPullRequest
31
33
  from lazy_github.ui.screens.confirm import ConfirmDialog
32
34
 
@@ -289,6 +291,7 @@ class CreateOrEditPullRequestContainer(VerticalScroll):
289
291
  "[yellow]Warning:[/yellow] Current local branch has no configured upstream", id="branch_missing"
290
292
  )
291
293
  self.branch_missing_label.display = False
294
+ self._draft_save_timer: Timer | None = None
292
295
 
293
296
  def compose(self) -> ComposeResult:
294
297
  pr_template = LazyGithubContext.config.pull_requests.pull_request_template
@@ -335,12 +338,96 @@ class CreateOrEditPullRequestContainer(VerticalScroll):
335
338
  if not does_branch_have_configured_upstream(LazyGithubContext.current_directory_branch):
336
339
  self.branch_missing_label.display = True
337
340
 
341
+ def _save_draft_state(self) -> None:
342
+ """Save the current form state as a draft, or clear if empty."""
343
+ if self.existing_pull_request or not LazyGithubContext.current_repo:
344
+ return
345
+
346
+ title = self.query_one("#pr_title", Input).value.strip()
347
+ description = self.query_one("#pr_description", TextArea).text.strip()
348
+ base_ref = self.query_one("#base_ref", Input).value
349
+ head_ref = self.query_one("#head_ref", Input).value
350
+ is_draft = self.query_one("#pr_is_draft", Switch).value
351
+ reviewers = list(self.query_one(ReviewerSelectionContainer).reviewers)
352
+
353
+ # If there's no meaningful content, clear any existing draft
354
+ if not title and not description and not reviewers:
355
+ clear_pr_draft(LazyGithubContext.current_repo.full_name)
356
+ return
357
+
358
+ draft = PullRequestDraft(
359
+ repo_full_name=LazyGithubContext.current_repo.full_name,
360
+ title=title,
361
+ description=description,
362
+ base_ref=base_ref,
363
+ head_ref=head_ref,
364
+ is_draft=is_draft,
365
+ reviewers=reviewers,
366
+ )
367
+ save_pr_draft(draft)
368
+
369
+ def _restore_draft_state(self) -> None:
370
+ """Restore form state from a saved draft if one exists."""
371
+ if self.existing_pull_request or not LazyGithubContext.current_repo:
372
+ return
373
+
374
+ draft = load_pr_draft(LazyGithubContext.current_repo.full_name)
375
+ if not draft:
376
+ return
377
+
378
+ # Check if draft has meaningful content
379
+ has_content = draft.title.strip() or draft.description.strip() or draft.reviewers
380
+
381
+ self.query_one("#pr_title", Input).value = draft.title
382
+ self.query_one("#pr_description", TextArea).text = draft.description
383
+ self.query_one("#base_ref", Input).value = draft.base_ref
384
+ self.query_one("#head_ref", Input).value = draft.head_ref
385
+ self.query_one("#pr_is_draft", Switch).value = draft.is_draft
386
+
387
+ reviewer_container = self.query_one(ReviewerSelectionContainer)
388
+ for reviewer in draft.reviewers:
389
+ reviewer_container.reviewers.add(reviewer)
390
+ try:
391
+ reviewer_container.reviewers_selection_list.add_option(
392
+ Selection(reviewer, reviewer, id=reviewer, initial_state=True)
393
+ )
394
+ except DuplicateID:
395
+ pass
396
+ if draft.reviewers:
397
+ reviewer_container.current_reviewers_label.display = True
398
+ reviewer_container.reviewers_selection_list.display = True
399
+
400
+ if has_content:
401
+ self.notify("Restored saved PR draft")
402
+
338
403
  async def on_mount(self) -> None:
339
404
  self.query_one("#pr_title", Input).focus()
340
405
  self.ensure_directory_branch_has_configured_upstream()
406
+ if not self.existing_pull_request:
407
+ self._restore_draft_state()
408
+
409
+ def _schedule_draft_save(self) -> None:
410
+ """Schedule a debounced draft save after 2 seconds of inactivity."""
411
+ if self.existing_pull_request:
412
+ return
413
+ if self._draft_save_timer is not None:
414
+ self._draft_save_timer.stop()
415
+ self._draft_save_timer = self.set_timer(2, self._save_draft_state)
416
+
417
+ @on(Input.Changed, "#pr_title")
418
+ @on(Input.Changed, "#base_ref")
419
+ @on(Input.Changed, "#head_ref")
420
+ def _on_input_changed(self, _: Input.Changed) -> None:
421
+ self._schedule_draft_save()
422
+
423
+ @on(TextArea.Changed, "#pr_description")
424
+ def _on_description_changed(self, _: TextArea.Changed) -> None:
425
+ self._schedule_draft_save()
341
426
 
342
427
  @on(Button.Pressed, "#cancel_new_pr")
343
428
  def cancel_pull_request(self, _: Button.Pressed):
429
+ if not self.existing_pull_request:
430
+ self._save_draft_state()
344
431
  self.app.pop_screen()
345
432
 
346
433
  async def _edit_pr(self) -> None:
@@ -418,6 +505,7 @@ class CreateOrEditPullRequestContainer(VerticalScroll):
418
505
  lg.info(f"Requesting PR reviews from: {', '.join(reviewers)}")
419
506
  await request_reviews(created_pr, reviewers)
420
507
 
508
+ clear_pr_draft(LazyGithubContext.current_repo.full_name)
421
509
  self.notify("Successfully created PR!")
422
510
  self.post_message(PullRequestCreatedOrUpdated(created_pr))
423
511
 
@@ -461,6 +549,9 @@ class CreateOrEditPullRequestModal(ModalScreen[FullPullRequest | None]):
461
549
  yield CreateOrEditPullRequestContainer(self.existing_pull_request)
462
550
 
463
551
  def action_close(self) -> None:
552
+ if not self.existing_pull_request:
553
+ container = self.query_one(CreateOrEditPullRequestContainer)
554
+ container._save_draft_state()
464
555
  self.dismiss(None)
465
556
 
466
557
  @on(PullRequestCreatedOrUpdated)
@@ -0,0 +1,89 @@
1
+ from textual import on
2
+ from textual.app import ComposeResult
3
+ from textual.containers import Container, Horizontal
4
+ from textual.content import Content
5
+ from textual.screen import ModalScreen
6
+ from textual.widgets import Button, Input, Label, Markdown, Rule
7
+
8
+ from lazy_github.lib.bindings import LazyGithubBindings
9
+ from lazy_github.lib.context import LazyGithubContext
10
+ from lazy_github.lib.github.backends.protocol import GithubApiRequestFailed
11
+ from lazy_github.lib.github.issues import get_issue_by_number
12
+ from lazy_github.models.github import Issue
13
+ from lazy_github.ui.widgets.common import LazyGithubFooter
14
+
15
+
16
+ class LookupIssueButtons(Horizontal):
17
+ DEFAULT_CSS = """
18
+ LookupIssueButtons {
19
+ align: center middle;
20
+ height: auto;
21
+ width: 100%;
22
+ }
23
+ Button {
24
+ margin: 1;
25
+ }
26
+ """
27
+
28
+ def compose(self) -> ComposeResult:
29
+ yield Button("Open", id="lookup", variant="success")
30
+ yield Button("Cancel", id="cancel", variant="error")
31
+
32
+
33
+ class LookupIssueContainer(Container):
34
+ DEFAULT_CSS = """
35
+ LookupIssueContainer {
36
+ align: center middle;
37
+ }
38
+ """
39
+
40
+ def compose(self) -> ComposeResult:
41
+ yield Markdown("# Search for an issue by number:")
42
+ yield Label(Content.from_markup("[bold]Issue Number:[/bold]"))
43
+ yield Input(
44
+ id="issue_number",
45
+ placeholder="Issue number",
46
+ type="number",
47
+ )
48
+ yield Rule()
49
+ yield LookupIssueButtons()
50
+
51
+
52
+ class LookupIssueModal(ModalScreen[Issue | None]):
53
+ DEFAULT_CSS = """
54
+ LookupIssueModal {
55
+ align: center middle;
56
+ content-align: center middle;
57
+ }
58
+
59
+ LookupIssueContainer {
60
+ width: 60;
61
+ max-height: 25;
62
+ border: thick $background 80%;
63
+ background: $surface-lighten-3;
64
+ }
65
+ """
66
+
67
+ BINDINGS = [LazyGithubBindings.SUBMIT_DIALOG, LazyGithubBindings.CLOSE_DIALOG]
68
+
69
+ def compose(self) -> ComposeResult:
70
+ yield LookupIssueContainer()
71
+ yield LazyGithubFooter()
72
+
73
+ @on(Button.Pressed, "#lookup")
74
+ async def action_submit(self) -> None:
75
+ assert LazyGithubContext.current_repo is not None, "Current repo is missing!"
76
+
77
+ try:
78
+ issue_number = int(self.query_one("#issue_number", Input).value)
79
+ issue = await get_issue_by_number(LazyGithubContext.current_repo, issue_number)
80
+ except ValueError:
81
+ self.notify("Must enter a valid issue number!", title="Invalid Issue Number", severity="error")
82
+ except GithubApiRequestFailed:
83
+ self.notify("Could not find issue!", title="Unknown Issue", severity="error")
84
+ else:
85
+ self.dismiss(issue)
86
+
87
+ @on(Button.Pressed, "#cancel")
88
+ async def action_close(self) -> None:
89
+ self.dismiss(None)
@@ -30,7 +30,7 @@ from lazy_github.lib.messages import (
30
30
  IssueSelected,
31
31
  PullRequestSelected,
32
32
  RepoSelected,
33
- ReviewsLoaded,
33
+ ReviewsAndCommentsLoaded,
34
34
  WorkflowRunSelected,
35
35
  )
36
36
  from lazy_github.models.github import Issue, PartialPullRequest, Repository, WorkflowRun
@@ -333,11 +333,11 @@ class MainViewPane(Container):
333
333
  if focus_run_details:
334
334
  tabbed_content.children[0].focus()
335
335
 
336
- @on(ReviewsLoaded)
337
- async def handle_reviews_loaded(self, message: ReviewsLoaded) -> None:
336
+ @on(ReviewsAndCommentsLoaded)
337
+ async def handle_reviews_loaded(self, message: ReviewsAndCommentsLoaded) -> None:
338
338
  try:
339
339
  overview_pane = self.query_one(PrOverviewTabPane)
340
- except WrongType | NoMatches:
340
+ except (WrongType, NoMatches):
341
341
  pass
342
342
  else:
343
343
  overview_pane.add_reviews(message.reviews)
@@ -480,9 +480,19 @@ class LazyGithubMainScreen(Screen):
480
480
 
481
481
  async def action_toggle_ui(self, ui_to_hide: str):
482
482
  widget = self.query_one(f"#{ui_to_hide}", Widget)
483
- new_value = not widget.visible
484
- widget.display = not new_value
485
- widget.visible = not new_value
483
+ new_value = not widget.display
484
+ widget.display = new_value
485
+ widget.visible = new_value
486
+
487
+ # If showing a section and we have a repo loaded, refresh its data
488
+ if new_value and LazyGithubContext.current_repo:
489
+ repo = LazyGithubContext.current_repo
490
+ selections = self.main_view_pane.selections
491
+ if ui_to_hide == "workflows":
492
+ selections.workflows.initialize_tables_from_cache(repo)
493
+ selections.workflows.load_repo(repo)
494
+ elif ui_to_hide in ("issues", "pull_requests"):
495
+ selections.fetch_issues_and_pull_requests(repo)
486
496
 
487
497
  async def action_show_settings_modal(self) -> None:
488
498
  self.app.push_screen(SettingsModal())