|
4 | 4 | import webbrowser |
5 | 5 | import platform |
6 | 6 | import threading |
| 7 | +import os |
7 | 8 | from application import get_app |
8 | 9 | from models.repository import Repository |
9 | | -from models.workflow import Workflow, WorkflowRun, WorkflowJob |
| 10 | +from models.workflow import Workflow, WorkflowRun, WorkflowJob, Artifact |
10 | 11 | from . import theme |
11 | 12 |
|
12 | 13 |
|
@@ -430,6 +431,9 @@ def init_ui(self): |
430 | 431 | self.open_job_btn = wx.Button(self.panel, label="Open &Job in Browser") |
431 | 432 | btn_sizer1.Add(self.open_job_btn, 0, wx.RIGHT, 5) |
432 | 433 |
|
| 434 | + self.artifacts_btn = wx.Button(self.panel, label="&Artifacts...") |
| 435 | + btn_sizer1.Add(self.artifacts_btn, 0, wx.RIGHT, 5) |
| 436 | + |
433 | 437 | self.close_btn = wx.Button(self.panel, wx.ID_CLOSE, label="Cl&ose") |
434 | 438 | btn_sizer1.Add(self.close_btn, 0) |
435 | 439 |
|
@@ -526,6 +530,7 @@ def bind_events(self): |
526 | 530 | self.cancel_btn.Bind(wx.EVT_BUTTON, self.on_cancel) |
527 | 531 | self.open_browser_btn.Bind(wx.EVT_BUTTON, self.on_open_browser) |
528 | 532 | self.open_job_btn.Bind(wx.EVT_BUTTON, self.on_open_job) |
| 533 | + self.artifacts_btn.Bind(wx.EVT_BUTTON, self.on_view_artifacts) |
529 | 534 | self.close_btn.Bind(wx.EVT_BUTTON, self.on_close) |
530 | 535 | self.jobs_list.Bind(wx.EVT_LISTBOX, self.on_job_selection_change) |
531 | 536 | self.jobs_list.Bind(wx.EVT_LISTBOX_DCLICK, self.on_view_logs) |
@@ -655,6 +660,315 @@ def on_view_logs(self, event): |
655 | 660 | dlg.ShowModal() |
656 | 661 | dlg.Destroy() |
657 | 662 |
|
| 663 | + def on_view_artifacts(self, event): |
| 664 | + """View and download artifacts produced by this run.""" |
| 665 | + dlg = ArtifactsDialog(self, self.repo, self.run) |
| 666 | + dlg.ShowModal() |
| 667 | + dlg.Destroy() |
| 668 | + |
| 669 | + def on_close(self, event): |
| 670 | + """Close dialog.""" |
| 671 | + self.EndModal(wx.ID_CLOSE) |
| 672 | + |
| 673 | + |
| 674 | +class ArtifactsDialog(wx.Dialog): |
| 675 | + """Dialog for viewing and downloading a workflow run's artifacts.""" |
| 676 | + |
| 677 | + def __init__(self, parent, repo: Repository, run: WorkflowRun): |
| 678 | + self.repo = repo |
| 679 | + self.run = run |
| 680 | + self.app = get_app() |
| 681 | + self.account = self.app.currentAccount |
| 682 | + self.artifacts = [] |
| 683 | + |
| 684 | + title = f"Artifacts - Run #{run.run_number}" |
| 685 | + wx.Dialog.__init__(self, parent, title=title, size=(700, 500)) |
| 686 | + |
| 687 | + self.init_ui() |
| 688 | + self.bind_events() |
| 689 | + theme.apply_theme(self) |
| 690 | + |
| 691 | + # Load artifacts |
| 692 | + self.load_artifacts() |
| 693 | + |
| 694 | + def init_ui(self): |
| 695 | + """Initialize the UI.""" |
| 696 | + self.panel = wx.Panel(self) |
| 697 | + main_sizer = wx.BoxSizer(wx.VERTICAL) |
| 698 | + |
| 699 | + # Artifacts list |
| 700 | + list_label = wx.StaticText(self.panel, label="&Artifacts:") |
| 701 | + main_sizer.Add(list_label, 0, wx.LEFT | wx.TOP, 10) |
| 702 | + |
| 703 | + self.artifacts_list = wx.ListBox(self.panel, style=wx.LB_SINGLE) |
| 704 | + main_sizer.Add(self.artifacts_list, 1, wx.EXPAND | wx.ALL, 10) |
| 705 | + |
| 706 | + # Buttons |
| 707 | + btn_sizer = wx.BoxSizer(wx.HORIZONTAL) |
| 708 | + |
| 709 | + self.download_btn = wx.Button(self.panel, label="&Download Selected") |
| 710 | + btn_sizer.Add(self.download_btn, 0, wx.RIGHT, 5) |
| 711 | + |
| 712 | + self.download_all_btn = wx.Button(self.panel, label="Download A&ll") |
| 713 | + btn_sizer.Add(self.download_all_btn, 0, wx.RIGHT, 5) |
| 714 | + |
| 715 | + self.refresh_btn = wx.Button(self.panel, label="&Refresh") |
| 716 | + btn_sizer.Add(self.refresh_btn, 0, wx.RIGHT, 5) |
| 717 | + |
| 718 | + self.open_browser_btn = wx.Button(self.panel, label="Open Run in &Browser") |
| 719 | + btn_sizer.Add(self.open_browser_btn, 0, wx.RIGHT, 5) |
| 720 | + |
| 721 | + self.close_btn = wx.Button(self.panel, wx.ID_CLOSE, label="Cl&ose") |
| 722 | + btn_sizer.Add(self.close_btn, 0) |
| 723 | + |
| 724 | + main_sizer.Add(btn_sizer, 0, wx.ALL | wx.ALIGN_CENTER, 10) |
| 725 | + |
| 726 | + self.panel.SetSizer(main_sizer) |
| 727 | + |
| 728 | + def bind_events(self): |
| 729 | + """Bind event handlers.""" |
| 730 | + self.Bind(wx.EVT_CLOSE, self.on_close) |
| 731 | + self.Bind(wx.EVT_CHAR_HOOK, self.on_char_hook) |
| 732 | + self.download_btn.Bind(wx.EVT_BUTTON, self.on_download) |
| 733 | + self.download_all_btn.Bind(wx.EVT_BUTTON, self.on_download_all) |
| 734 | + self.refresh_btn.Bind(wx.EVT_BUTTON, self.on_refresh) |
| 735 | + self.open_browser_btn.Bind(wx.EVT_BUTTON, self.on_open_browser) |
| 736 | + self.close_btn.Bind(wx.EVT_BUTTON, self.on_close) |
| 737 | + self.artifacts_list.Bind(wx.EVT_LISTBOX, self.on_selection_change) |
| 738 | + self.artifacts_list.Bind(wx.EVT_LISTBOX_DCLICK, self.on_download) |
| 739 | + self.artifacts_list.Bind(wx.EVT_KEY_DOWN, self.on_key) |
| 740 | + |
| 741 | + def on_char_hook(self, event): |
| 742 | + """Handle key events.""" |
| 743 | + if event.GetKeyCode() == wx.WXK_ESCAPE: |
| 744 | + self.on_close(None) |
| 745 | + else: |
| 746 | + event.Skip() |
| 747 | + |
| 748 | + def load_artifacts(self): |
| 749 | + """Load artifacts in background.""" |
| 750 | + self.artifacts_list.Clear() |
| 751 | + self.artifacts_list.Append("Loading artifacts...") |
| 752 | + self.artifacts = [] |
| 753 | + self.update_buttons() |
| 754 | + |
| 755 | + def do_load(): |
| 756 | + artifacts = self.account.get_run_artifacts( |
| 757 | + self.repo.owner, self.repo.name, self.run.id |
| 758 | + ) |
| 759 | + wx.CallAfter(self.update_artifacts_list, artifacts) |
| 760 | + |
| 761 | + threading.Thread(target=do_load, daemon=True).start() |
| 762 | + |
| 763 | + def update_artifacts_list(self, artifacts): |
| 764 | + """Update the artifacts list.""" |
| 765 | + self.artifacts = artifacts |
| 766 | + self.artifacts_list.Clear() |
| 767 | + |
| 768 | + if not artifacts: |
| 769 | + self.artifacts_list.Append("No artifacts found") |
| 770 | + else: |
| 771 | + for artifact in artifacts: |
| 772 | + self.artifacts_list.Append(artifact.format_display()) |
| 773 | + |
| 774 | + self.update_buttons() |
| 775 | + |
| 776 | + def update_buttons(self): |
| 777 | + """Update button states based on selection.""" |
| 778 | + artifact = self.get_selected_artifact() |
| 779 | + can_download = artifact is not None and not artifact.expired |
| 780 | + |
| 781 | + self.download_btn.Enable(can_download) |
| 782 | + # Download-all is useful only if at least one artifact is downloadable |
| 783 | + self.download_all_btn.Enable(any(not a.expired for a in self.artifacts)) |
| 784 | + |
| 785 | + def get_selected_artifact(self) -> Artifact | None: |
| 786 | + """Get the currently selected artifact.""" |
| 787 | + selection = self.artifacts_list.GetSelection() |
| 788 | + if selection != wx.NOT_FOUND and selection < len(self.artifacts): |
| 789 | + return self.artifacts[selection] |
| 790 | + return None |
| 791 | + |
| 792 | + def on_selection_change(self, event): |
| 793 | + """Handle selection change.""" |
| 794 | + self.update_buttons() |
| 795 | + |
| 796 | + def on_refresh(self, event): |
| 797 | + """Refresh the artifacts list.""" |
| 798 | + self.load_artifacts() |
| 799 | + |
| 800 | + def _download_dir(self) -> str | None: |
| 801 | + """Return the download directory, creating it if needed. None on failure.""" |
| 802 | + download_dir = self.app.prefs.download_location |
| 803 | + if not os.path.exists(download_dir): |
| 804 | + try: |
| 805 | + os.makedirs(download_dir) |
| 806 | + except Exception as e: |
| 807 | + wx.MessageBox( |
| 808 | + f"Could not create download directory:\n{e}", |
| 809 | + "Error", |
| 810 | + wx.OK | wx.ICON_ERROR |
| 811 | + ) |
| 812 | + return None |
| 813 | + return download_dir |
| 814 | + |
| 815 | + def _dest_path(self, download_dir: str, artifact: Artifact) -> str: |
| 816 | + """Artifacts are delivered as zips; save them with a .zip extension. |
| 817 | +
|
| 818 | + The name comes from the API, so strip any path components to keep the |
| 819 | + file inside the chosen download directory. |
| 820 | + """ |
| 821 | + name = os.path.basename(artifact.name) |
| 822 | + if not name.lower().endswith(".zip"): |
| 823 | + name = f"{name}.zip" |
| 824 | + return os.path.join(download_dir, name) |
| 825 | + |
| 826 | + def on_download(self, event): |
| 827 | + """Download the selected artifact.""" |
| 828 | + artifact = self.get_selected_artifact() |
| 829 | + if not artifact: |
| 830 | + return |
| 831 | + |
| 832 | + if artifact.expired: |
| 833 | + wx.MessageBox( |
| 834 | + "This artifact has expired and can no longer be downloaded.", |
| 835 | + "Artifact Expired", |
| 836 | + wx.OK | wx.ICON_WARNING |
| 837 | + ) |
| 838 | + return |
| 839 | + |
| 840 | + download_dir = self._download_dir() |
| 841 | + if download_dir is None: |
| 842 | + return |
| 843 | + |
| 844 | + dest_path = self._dest_path(download_dir, artifact) |
| 845 | + |
| 846 | + if os.path.exists(dest_path): |
| 847 | + result = wx.MessageBox( |
| 848 | + f"File already exists:\n{dest_path}\n\nOverwrite?", |
| 849 | + "File Exists", |
| 850 | + wx.YES_NO | wx.ICON_QUESTION |
| 851 | + ) |
| 852 | + if result != wx.YES: |
| 853 | + return |
| 854 | + |
| 855 | + # A progress dialog so large artifacts don't look frozen while they |
| 856 | + # stream (the win-x64 builds are ~90 MB). |
| 857 | + progress = wx.ProgressDialog( |
| 858 | + "Downloading Artifact", |
| 859 | + f"Downloading {artifact.name}...", |
| 860 | + maximum=100, |
| 861 | + parent=self, |
| 862 | + style=wx.PD_APP_MODAL | wx.PD_AUTO_HIDE | wx.PD_ELAPSED_TIME |
| 863 | + ) |
| 864 | + state = {"done": False} |
| 865 | + |
| 866 | + def progress_cb(downloaded, total): |
| 867 | + def update(): |
| 868 | + if state["done"]: |
| 869 | + return |
| 870 | + if total: |
| 871 | + progress.Update(min(int(downloaded * 100 / total), 100)) |
| 872 | + else: |
| 873 | + progress.Pulse() |
| 874 | + wx.CallAfter(update) |
| 875 | + |
| 876 | + def do_download(): |
| 877 | + success = self.account.download_artifact( |
| 878 | + self.repo.owner, self.repo.name, artifact.id, dest_path, |
| 879 | + progress_callback=progress_cb |
| 880 | + ) |
| 881 | + wx.CallAfter(download_complete, success) |
| 882 | + |
| 883 | + def download_complete(success): |
| 884 | + state["done"] = True |
| 885 | + progress.Destroy() |
| 886 | + if success: |
| 887 | + wx.MessageBox( |
| 888 | + f"Downloaded:\n{dest_path}", |
| 889 | + "Download Complete", |
| 890 | + wx.OK | wx.ICON_INFORMATION |
| 891 | + ) |
| 892 | + else: |
| 893 | + wx.MessageBox( |
| 894 | + f"Failed to download {artifact.name}.", |
| 895 | + "Error", |
| 896 | + wx.OK | wx.ICON_ERROR |
| 897 | + ) |
| 898 | + |
| 899 | + threading.Thread(target=do_download, daemon=True).start() |
| 900 | + |
| 901 | + def on_download_all(self, event): |
| 902 | + """Download all non-expired artifacts in the background.""" |
| 903 | + downloadable = [a for a in self.artifacts if not a.expired] |
| 904 | + if not downloadable: |
| 905 | + return |
| 906 | + |
| 907 | + result = wx.MessageBox( |
| 908 | + f"Download {len(downloadable)} artifact(s) in background?", |
| 909 | + "Confirm Download", |
| 910 | + wx.YES_NO | wx.ICON_QUESTION |
| 911 | + ) |
| 912 | + if result != wx.YES: |
| 913 | + return |
| 914 | + |
| 915 | + download_dir = self._download_dir() |
| 916 | + if download_dir is None: |
| 917 | + return |
| 918 | + |
| 919 | + # Warn once if any of the targets already exist, matching the |
| 920 | + # single-download and release-asset flows. |
| 921 | + existing = [a for a in downloadable |
| 922 | + if os.path.exists(self._dest_path(download_dir, a))] |
| 923 | + if existing: |
| 924 | + result = wx.MessageBox( |
| 925 | + f"{len(existing)} file(s) already exist. Overwrite?", |
| 926 | + "Files Exist", |
| 927 | + wx.YES_NO | wx.ICON_QUESTION |
| 928 | + ) |
| 929 | + if result != wx.YES: |
| 930 | + return |
| 931 | + |
| 932 | + def do_download_all(): |
| 933 | + succeeded = 0 |
| 934 | + failed = 0 |
| 935 | + for artifact in downloadable: |
| 936 | + dest_path = self._dest_path(download_dir, artifact) |
| 937 | + if self.account.download_artifact( |
| 938 | + self.repo.owner, self.repo.name, artifact.id, dest_path |
| 939 | + ): |
| 940 | + succeeded += 1 |
| 941 | + else: |
| 942 | + failed += 1 |
| 943 | + wx.CallAfter(download_all_complete, succeeded, failed) |
| 944 | + |
| 945 | + def download_all_complete(succeeded, failed): |
| 946 | + if failed == 0: |
| 947 | + wx.MessageBox( |
| 948 | + f"Downloaded {succeeded} artifact(s) to:\n{download_dir}", |
| 949 | + "Download Complete", |
| 950 | + wx.OK | wx.ICON_INFORMATION |
| 951 | + ) |
| 952 | + else: |
| 953 | + wx.MessageBox( |
| 954 | + f"Downloaded {succeeded} artifact(s), {failed} failed.\n\nLocation: {download_dir}", |
| 955 | + "Download Complete", |
| 956 | + wx.OK | wx.ICON_WARNING |
| 957 | + ) |
| 958 | + |
| 959 | + threading.Thread(target=do_download_all, daemon=True).start() |
| 960 | + |
| 961 | + def on_open_browser(self, event): |
| 962 | + """Open the workflow run in the browser.""" |
| 963 | + webbrowser.open(self.run.html_url) |
| 964 | + |
| 965 | + def on_key(self, event): |
| 966 | + """Handle key events.""" |
| 967 | + if event.GetKeyCode() == wx.WXK_RETURN: |
| 968 | + self.on_download(None) |
| 969 | + else: |
| 970 | + event.Skip() |
| 971 | + |
658 | 972 | def on_close(self, event): |
659 | 973 | """Close dialog.""" |
660 | 974 | self.EndModal(wx.ID_CLOSE) |
|
0 commit comments