diff --git a/internal/command/album.go b/internal/command/album.go index 2eb71f4..5c0089a 100644 --- a/internal/command/album.go +++ b/internal/command/album.go @@ -25,6 +25,7 @@ import ( "github.com/tickstep/aliyunpan/internal/functions/pandownload" "github.com/tickstep/aliyunpan/internal/global" "github.com/tickstep/aliyunpan/internal/taskframework" + "github.com/tickstep/aliyunpan/internal/ui" "github.com/tickstep/aliyunpan/internal/utils" "github.com/tickstep/aliyunpan/library/requester/transfer" "github.com/tickstep/library-go/converter" @@ -280,8 +281,6 @@ func RunShareAlbumDownloadFile(albumNames []string, options *DownloadOptions) { } } - fmt.Printf("\n[0] 当前文件下载最大并发量为: %d, 下载缓存为: %s\n\n", options.Parallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) - var ( panClient = activeUser.PanClient() ) @@ -300,10 +299,29 @@ func RunShareAlbumDownloadFile(albumNames []string, options *DownloadOptions) { // 全局速度统计 globalSpeedsStat := &speeds.Speeds{} + var dashboard *ui.DownloadDashboard + if options.ShowProgress && !options.IsPrintStatus && ui.IsTerminal(os.Stdout) { + dashboard = ui.NewDownloadDashboard(cfg.MaxParallel, globalSpeedsStat, &ui.DownloadDashboardOptions{ + Title: "AliyunPan CLI - 下载中心", + }) + } + logf := func(format string, a ...interface{}) { + if dashboard != nil { + dashboard.Logf(format, a...) + return + } + fmt.Printf(format, a...) + } + if dashboard != nil { + dashboard.Logf("[0] 当前文件下载最大并发量为: %d, 下载缓存为: %s", options.Parallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) + } else { + fmt.Printf("\n[0] 当前文件下载最大并发量为: %d, 下载缓存为: %s\n\n", options.Parallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) + } + // 处理队列 allShareAlbumList, err := activeUser.PanClient().OpenapiPanClient().ShareAlbumListGetAll() if err != nil { - fmt.Printf("获取相簿列表失败: %s\n", err) + logf("获取相簿列表失败: %s\n", err) return } for k := range albumNames { @@ -323,11 +341,11 @@ func RunShareAlbumDownloadFile(albumNames []string, options *DownloadOptions) { Limit: 100, }) if er != nil { - fmt.Printf("获取相簿文件出错,请稍后重试: %s\n", albumNames[k]) + logf("获取相簿文件出错,请稍后重试: %s\n", albumNames[k]) continue } if fileList == nil || len(fileList) == 0 { - fmt.Printf("相簿里面没有文件: %s\n", albumNames[k]) + logf("相簿里面没有文件: %s\n", albumNames[k]) continue } @@ -350,7 +368,7 @@ func RunShareAlbumDownloadFile(albumNames []string, options *DownloadOptions) { }) if apierr != nil { logger.Verbosef("ERROR: get album file download url error: %s\n", f.FileId) - fmt.Printf("\n下载照片失败: %s\n", f.FileName) + logf("下载照片失败: %s\n", f.FileName) continue } if durl.StreamsUrl != nil { // 实况图片(照片+视频)下载链接 @@ -404,6 +422,7 @@ func RunShareAlbumDownloadFile(albumNames []string, options *DownloadOptions) { DriveId: f.DriveId, // 一个相簿的文件会来自多个网盘(资源库/备份盘) GlobalSpeedsStat: globalSpeedsStat, FileRecorder: nil, + UI: dashboard, } // 设置相簿文件信息 @@ -419,7 +438,10 @@ func RunShareAlbumDownloadFile(albumNames []string, options *DownloadOptions) { unit.SavePath = GetActiveUser().GetSavePath(f.Path) } info := executor.Append(&unit, options.MaxRetry) - fmt.Printf("[%s] 加入下载队列: %s\n", info.Id(), f.Path) + if dashboard != nil { + dashboard.RegisterTask(info.Id(), f.Path, f.FileSize, f.IsFile()) + } + logf("[%s] 加入下载队列: %s\n", info.Id(), f.Path) } } @@ -427,7 +449,13 @@ func RunShareAlbumDownloadFile(albumNames []string, options *DownloadOptions) { statistic.StartTimer() // 开始执行 + if dashboard != nil { + dashboard.Start() + } executor.Execute() + if dashboard != nil { + dashboard.Close() + } fmt.Printf("\n下载结束, 时间: %s, 数据总量: %s\n", utils.ConvertTime(statistic.Elapsed()), converter.ConvertFileSize(statistic.TotalSize(), 2)) diff --git a/internal/command/album_web.go b/internal/command/album_web.go index 81c4e15..cf599d7 100644 --- a/internal/command/album_web.go +++ b/internal/command/album_web.go @@ -26,6 +26,7 @@ import ( "github.com/tickstep/aliyunpan/internal/functions/pandownload" "github.com/tickstep/aliyunpan/internal/functions/panlogin" "github.com/tickstep/aliyunpan/internal/taskframework" + "github.com/tickstep/aliyunpan/internal/ui" "github.com/tickstep/aliyunpan/internal/utils" "github.com/tickstep/aliyunpan/library/requester/transfer" "github.com/tickstep/library-go/converter" @@ -688,8 +689,6 @@ func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) { } } - fmt.Printf("\n[0] 当前文件下载最大并发量为: %d, 下载缓存为: %s\n\n", options.Parallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) - var ( panClient = activeUser.PanClient() ) @@ -707,6 +706,25 @@ func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) { // 全局速度统计 globalSpeedsStat := &speeds.Speeds{} + var dashboard *ui.DownloadDashboard + if options.ShowProgress && !options.IsPrintStatus && ui.IsTerminal(os.Stdout) { + dashboard = ui.NewDownloadDashboard(cfg.MaxParallel, globalSpeedsStat, &ui.DownloadDashboardOptions{ + Title: "AliyunPan CLI - 下载中心", + }) + } + logf := func(format string, a ...interface{}) { + if dashboard != nil { + dashboard.Logf(format, a...) + return + } + fmt.Printf(format, a...) + } + if dashboard != nil { + dashboard.Logf("[0] 当前文件下载最大并发量为: %d, 下载缓存为: %s", options.Parallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) + } else { + fmt.Printf("\n[0] 当前文件下载最大并发量为: %d, 下载缓存为: %s\n\n", options.Parallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) + } + // 处理队列 for k := range albumNames { record := getAlbumFromName(activeUser, albumNames[k]) @@ -718,11 +736,11 @@ func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) { AlbumId: record.AlbumId, }) if er != nil { - fmt.Printf("获取相簿文件出错,请稍后重试: %s\n", albumNames[k]) + logf("获取相簿文件出错,请稍后重试: %s\n", albumNames[k]) continue } if fileList == nil || len(fileList) == 0 { - fmt.Printf("相簿里面没有文件: %s\n", albumNames[k]) + logf("相簿里面没有文件: %s\n", albumNames[k]) continue } for _, f := range fileList { @@ -746,6 +764,7 @@ func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) { DriveId: f.DriveId, // 必须使用文件的DriveId,因为一个相簿的文件会来自多个网盘(资源库/备份盘) GlobalSpeedsStat: globalSpeedsStat, FileRecorder: nil, + UI: dashboard, } // TODO: 相册下载需要重构 @@ -762,7 +781,10 @@ func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) { unit.SavePath = GetActiveUser().GetSavePath(f.Path) } info := executor.Append(&unit, options.MaxRetry) - fmt.Printf("[%s] 加入下载队列: %s\n", info.Id(), f.Path) + if dashboard != nil { + dashboard.RegisterTask(info.Id(), f.Path, f.FileSize, f.IsFile()) + } + logf("[%s] 加入下载队列: %s\n", info.Id(), f.Path) } } @@ -770,7 +792,13 @@ func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) { statistic.StartTimer() // 开始执行 + if dashboard != nil { + dashboard.Start() + } executor.Execute() + if dashboard != nil { + dashboard.Close() + } fmt.Printf("\n下载结束, 时间: %s, 数据总量: %s\n", utils.ConvertTime(statistic.Elapsed()), converter.ConvertFileSize(statistic.TotalSize(), 2)) diff --git a/internal/command/download.go b/internal/command/download.go index 0dd27a2..939a296 100644 --- a/internal/command/download.go +++ b/internal/command/download.go @@ -23,6 +23,7 @@ import ( "github.com/tickstep/aliyunpan/internal/global" "github.com/tickstep/aliyunpan/internal/log" "github.com/tickstep/aliyunpan/internal/taskframework" + "github.com/tickstep/aliyunpan/internal/ui" "github.com/tickstep/aliyunpan/internal/utils" "github.com/tickstep/aliyunpan/library/requester/transfer" "github.com/tickstep/library-go/converter" @@ -326,6 +327,8 @@ func RunDownload(paths []string, options *DownloadOptions) { // 多用户下载的辅助账号列表 var subPanClientList []*config.PanClient + var multiUserNotice string + var multiUserErrorNotice string if options.IsMultiUserDownload { // 多用户下载 c := config.Config for _, u := range config.Config.UserList { @@ -349,21 +352,19 @@ func RunDownload(paths []string, options *DownloadOptions) { } if subPanClientList == nil || len(subPanClientList) == 0 { - fmt.Printf("\n当前登录用户只有一个,无法启用多用户联合下载\n") + multiUserErrorNotice = "当前登录用户只有一个,无法启用多用户联合下载" subPanClientList = nil } } if subPanClientList != nil || len(subPanClientList) > 0 { // 已启用多用户下载 userCount := len(subPanClientList) + 1 - fmt.Printf("\n*** 已启用多用户联合下载,用户数: %d ***\n", userCount) + multiUserNotice = fmt.Sprintf("*** 已启用多用户联合下载,用户数: %d ***", userCount) // 多用户下载,并发数必须为1,以获得最大下载速度 options.Parallel = 1 // 阿里OpenAPI规定:文件分片下载的并发数为3,即某用户使用 App 时,可以同时下载 1 个文件的 3 个分片,或者同时下载 3 个文件的各 1 个分片。 options.SliceParallel = userCount * 3 } - fmt.Printf("\n[0] 当前文件下载最大并发量为: %d, 单文件下载分片线程数为: %d, 下载缓存为: %s\n", options.Parallel, options.SliceParallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) - var ( panClient = activeUser.PanClient() ) @@ -382,6 +383,39 @@ func RunDownload(paths []string, options *DownloadOptions) { // 全局速度统计 globalSpeedsStat := &speeds.Speeds{} + var dashboard *ui.DownloadDashboard + if options.ShowProgress && !options.IsPrintStatus && ui.IsTerminal(os.Stdout) { + dashboard = ui.NewDownloadDashboard(cfg.MaxParallel, globalSpeedsStat, &ui.DownloadDashboardOptions{ + Title: "AliyunPan CLI - 下载中心", + }) + } + logf := func(format string, a ...interface{}) { + if dashboard != nil { + dashboard.Logf(format, a...) + return + } + fmt.Printf(format, a...) + } + if multiUserErrorNotice != "" { + if dashboard != nil { + dashboard.Logf("%s", multiUserErrorNotice) + } else { + fmt.Printf("\n%s\n", multiUserErrorNotice) + } + } + if multiUserNotice != "" { + if dashboard != nil { + dashboard.Logf("%s", multiUserNotice) + } else { + fmt.Printf("\n%s\n", multiUserNotice) + } + } + if dashboard != nil { + dashboard.Logf("[0] 当前文件下载最大并发量为: %d, 单文件下载分片线程数为: %d, 下载缓存为: %s", options.Parallel, options.SliceParallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) + } else { + fmt.Printf("\n[0] 当前文件下载最大并发量为: %d, 单文件下载分片线程数为: %d, 下载缓存为: %s\n", options.Parallel, options.SliceParallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) + } + // 下载记录器 fileRecorder := log.NewFileRecorder(config.GetLogDir() + "/download_file_records.csv") @@ -390,12 +424,12 @@ func RunDownload(paths []string, options *DownloadOptions) { // 使用通配符匹配 fileList, err2 := matchPathByShellPattern(options.DriveId, paths[k]) if err2 != nil { - fmt.Printf("获取文件出错,请稍后重试: %s\n", paths[k]) + logf("获取文件出错,请稍后重试: %s\n", paths[k]) continue } if fileList == nil || len(fileList) == 0 { // 文件不存在 - fmt.Printf("文件不存在: %s\n", paths[k]) + logf("文件不存在: %s\n", paths[k]) continue } // 排序,按名称排序,从小到大 @@ -408,7 +442,7 @@ func RunDownload(paths []string, options *DownloadOptions) { // 是否排除下载 if utils.IsExcludeFile(f.Path, &newCfg.ExcludeNames) { - fmt.Printf("排除文件: %s\n", f.Path) + logf("排除文件: %s\n", f.Path) continue } @@ -431,6 +465,7 @@ func RunDownload(paths []string, options *DownloadOptions) { DriveId: options.DriveId, GlobalSpeedsStat: globalSpeedsStat, FileRecorder: fileRecorder, + UI: dashboard, } // 设置储存的路径 @@ -443,7 +478,10 @@ func RunDownload(paths []string, options *DownloadOptions) { unit.SavePath = GetActiveUser().GetSavePath(f.Path) } info := executor.Append(&unit, options.MaxRetry) - fmt.Printf("[%s] 加入下载队列: %s\n", info.Id(), f.Path) + if dashboard != nil { + dashboard.RegisterTask(info.Id(), f.Path, f.FileSize, f.IsFile()) + } + logf("[%s] 加入下载队列: %s\n", info.Id(), f.Path) } } @@ -451,7 +489,13 @@ func RunDownload(paths []string, options *DownloadOptions) { statistic.StartTimer() // 开始执行 + if dashboard != nil { + dashboard.Start() + } executor.Execute() + if dashboard != nil { + dashboard.Close() + } fmt.Printf("\n下载结束, 时间: %s, 数据总量: %s\n", utils.ConvertTime(statistic.Elapsed()), converter.ConvertFileSize(statistic.TotalSize(), 2)) diff --git a/internal/functions/pandownload/download_task_unit.go b/internal/functions/pandownload/download_task_unit.go index c0797f6..4e839b5 100644 --- a/internal/functions/pandownload/download_task_unit.go +++ b/internal/functions/pandownload/download_task_unit.go @@ -26,6 +26,7 @@ import ( "github.com/tickstep/aliyunpan/internal/log" "github.com/tickstep/aliyunpan/internal/plugins" "github.com/tickstep/aliyunpan/internal/taskframework" + "github.com/tickstep/aliyunpan/internal/ui" "github.com/tickstep/aliyunpan/internal/utils" "github.com/tickstep/aliyunpan/library/requester/transfer" "github.com/tickstep/library-go/converter" @@ -74,6 +75,9 @@ type ( // 下载文件记录器 FileRecorder *log.FileRecorder + + UI *ui.DownloadDashboard + skipReason string } ) @@ -110,6 +114,21 @@ func (dtu *DownloadTaskUnit) verboseInfof(format string, a ...interface{}) { } } +func (dtu *DownloadTaskUnit) logf(format string, a ...interface{}) { + if dtu.UI != nil { + dtu.UI.Logf(format, a...) + return + } + fmt.Printf(format, a...) +} + +func (dtu *DownloadTaskUnit) setUIState(state ui.TaskState, message string) { + if dtu.UI == nil { + return + } + dtu.UI.MarkTaskState(dtu.taskInfo.Id(), state, message) +} + // download 执行下载文件(非目录) func (dtu *DownloadTaskUnit) download() (err error) { var ( @@ -189,6 +208,10 @@ func (dtu *DownloadTaskUnit) download() (err error) { // 这里用共享变量的方式 isComplete := false der.OnDownloadStatusEvent(func(status transfer.DownloadStatuser, workersCallback func(downloader.RangeWorkerFunc)) { + if dtu.UI != nil { + dtu.UI.UpdateTaskProgress(dtu.taskInfo.Id(), status.Downloaded(), status.TotalSize(), status.SpeedsPerSecond(), status.TimeLeft()) + return + } // 这里可能会下载结束了, 还会输出内容 builder := &strings.Builder{} if dtu.IsPrintStatus { @@ -236,7 +259,7 @@ func (dtu *DownloadTaskUnit) download() (err error) { }) der.OnExecute(func() { - fmt.Printf("[%s] 下载开始\n", dtu.taskInfo.Id()) + dtu.logf("[%s] 下载开始\n", dtu.taskInfo.Id()) }) err = der.Execute() @@ -253,7 +276,7 @@ func (dtu *DownloadTaskUnit) download() (err error) { if removeErr != nil { dtu.verboseInfof("[%s] remove file error: %s\n", dtu.taskInfo.Id(), removeErr) } - fmt.Printf("[%s] 下载失败,文件不合法或者被禁止下载: %s\n", dtu.taskInfo.Id(), dtu.SavePath) + dtu.logf("[%s] 下载失败,文件不合法或者被禁止下载: %s\n", dtu.taskInfo.Id(), dtu.SavePath) return err } else { // 下载发生错误 @@ -278,10 +301,12 @@ func (dtu *DownloadTaskUnit) download() (err error) { if dtu.IsExecutedPermission { err = file.Chmod(0766) if err != nil { - fmt.Printf("[%s] 警告, 加执行权限错误: %s\n", dtu.taskInfo.Id(), err) + dtu.logf("[%s] 警告, 加执行权限错误: %s\n", dtu.taskInfo.Id(), err) } } - fmt.Printf("\n[%s] 下载完成, 保存位置: %s\n", dtu.taskInfo.Id(), dtu.SavePath) + if dtu.UI == nil { + fmt.Printf("\n[%s] 下载完成, 保存位置: %s\n", dtu.taskInfo.Id(), dtu.SavePath) + } return nil } @@ -321,7 +346,9 @@ func (dtu *DownloadTaskUnit) checkFileValid(result *taskframework.TaskUnitRunRes if dtu.fileInfo.FileSize >= 128*converter.MB { // 大文件, 输出一句提示消息 - fmt.Printf("[%s] 开始检验文件有效性, 请稍候...\n", dtu.taskInfo.Id()) + if dtu.UI == nil { + fmt.Printf("[%s] 开始检验文件有效性, 请稍候...\n", dtu.taskInfo.Id()) + } } // 就在这里处理校验出错 @@ -334,7 +361,9 @@ func (dtu *DownloadTaskUnit) checkFileValid(result *taskframework.TaskUnitRunRes // 文件不支持校验 result.ResultMessage = "检验文件有效性" result.Err = err - fmt.Printf("[%s] 检验文件有效性: %s\n", dtu.taskInfo.Id(), err) + if dtu.UI == nil { + fmt.Printf("[%s] 检验文件有效性: %s\n", dtu.taskInfo.Id(), err) + } return true case ErrDownloadFileBanned: // 违规文件 @@ -352,7 +381,9 @@ func (dtu *DownloadTaskUnit) checkFileValid(result *taskframework.TaskUnitRunRes } } - fmt.Printf("[%s] 检验文件有效性成功: %s\n", dtu.taskInfo.Id(), dtu.SavePath) + if dtu.UI == nil { + fmt.Printf("[%s] 检验文件有效性成功: %s\n", dtu.taskInfo.Id(), dtu.SavePath) + } return true } @@ -360,15 +391,20 @@ func (dtu *DownloadTaskUnit) OnRetry(lastRunResult *taskframework.TaskUnitRunRes // 输出错误信息 if lastRunResult.Err == nil { // result中不包含Err, 忽略输出 - fmt.Printf("[%s] %s, 重试 %d/%d\n", dtu.taskInfo.Id(), lastRunResult.ResultMessage, dtu.taskInfo.Retry(), dtu.taskInfo.MaxRetry()) + dtu.logf("[%s] %s, 重试 %d/%d\n", dtu.taskInfo.Id(), lastRunResult.ResultMessage, dtu.taskInfo.Retry(), dtu.taskInfo.MaxRetry()) return } - fmt.Printf("[%s] %s, %s, 重试 %d/%d\n", dtu.taskInfo.Id(), lastRunResult.ResultMessage, lastRunResult.Err, dtu.taskInfo.Retry(), dtu.taskInfo.MaxRetry()) + dtu.logf("[%s] %s, %s, 重试 %d/%d\n", dtu.taskInfo.Id(), lastRunResult.ResultMessage, lastRunResult.Err, dtu.taskInfo.Retry(), dtu.taskInfo.MaxRetry()) } func (dtu *DownloadTaskUnit) OnSuccess(lastRunResult *taskframework.TaskUnitRunResult) { // 执行插件 dtu.pluginCallback("success") + if dtu.skipReason != "" { + dtu.setUIState(ui.TaskSkipped, dtu.skipReason) + } else { + dtu.setUIState(ui.TaskSuccess, "") + } // 下载文件数据记录 if config.Config.FileRecordConfig == "1" { @@ -388,14 +424,19 @@ func (dtu *DownloadTaskUnit) OnSuccess(lastRunResult *taskframework.TaskUnitRunR func (dtu *DownloadTaskUnit) OnFailed(lastRunResult *taskframework.TaskUnitRunResult) { // 失败 dtu.pluginCallback("fail") + failedMessage := lastRunResult.ResultMessage + if lastRunResult.Err != nil { + failedMessage = fmt.Sprintf("%s, %s", lastRunResult.ResultMessage, lastRunResult.Err) + } + dtu.setUIState(ui.TaskFailed, failedMessage) // 失败 if lastRunResult.Err == nil { // result中不包含Err, 忽略输出 - fmt.Printf("[%s] %s\n", dtu.taskInfo.Id(), lastRunResult.ResultMessage) + dtu.logf("[%s] %s\n", dtu.taskInfo.Id(), lastRunResult.ResultMessage) return } - fmt.Printf("[%s] %s, %s\n", dtu.taskInfo.Id(), lastRunResult.ResultMessage, lastRunResult.Err) + dtu.logf("[%s] %s, %s\n", dtu.taskInfo.Id(), lastRunResult.ResultMessage, lastRunResult.Err) } func (dtu *DownloadTaskUnit) pluginCallback(result string) { @@ -428,7 +469,7 @@ func (dtu *DownloadTaskUnit) OnComplete(lastRunResult *taskframework.TaskUnitRun } func (dtu *DownloadTaskUnit) OnCancel(lastRunResult *taskframework.TaskUnitRunResult) { - + dtu.setUIState(ui.TaskCanceled, "取消: "+dtu.FilePanPath) } func (dtu *DownloadTaskUnit) RetryWait() time.Duration { @@ -437,6 +478,7 @@ func (dtu *DownloadTaskUnit) RetryWait() time.Duration { func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) { result = &taskframework.TaskUnitRunResult{} + dtu.setUIState(ui.TaskRunning, "") // 获取文件信息 var apierr *apierror.ApiError if dtu.fileInfo == nil || dtu.taskInfo.Retry() > 0 { @@ -453,10 +495,15 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) { } time.Sleep(1 * time.Second) } + if dtu.UI != nil && dtu.fileInfo != nil { + dtu.UI.UpdateTaskInfo(dtu.taskInfo.Id(), dtu.fileInfo.FileName, dtu.fileInfo.FileSize, dtu.fileInfo.IsFile()) + } // 输出文件信息 - fmt.Print("\n") - fmt.Printf("[%s] ----\n%s\n", dtu.taskInfo.Id(), dtu.fileInfo.String()) + if dtu.UI == nil { + fmt.Print("\n") + fmt.Printf("[%s] ----\n%s\n", dtu.taskInfo.Id(), dtu.fileInfo.String()) + } // 调用插件 ft := "file" @@ -481,7 +528,8 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) { if downloadFilePrepareResult, er := plugin.DownloadFilePrepareCallback(plugins.GetContext(config.Config.ActiveUser()), pluginParam); er == nil && downloadFilePrepareResult != nil { if strings.Compare("yes", downloadFilePrepareResult.DownloadApproved) != 0 { // skip download this file - fmt.Printf("插件取消了该文件下载: %s\n", dtu.fileInfo.Path) + dtu.logf("插件取消了该文件下载: %s\n", dtu.fileInfo.Path) + dtu.setUIState(ui.TaskCanceled, "取消: "+dtu.fileInfo.Path) result.Succeed = false result.Cancel = true return @@ -490,7 +538,7 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) { targetSaveRelativePath := strings.TrimPrefix(downloadFilePrepareResult.LocalFilePath, "/") targetSaveRelativePath = strings.TrimPrefix(targetSaveRelativePath, "\\") dtu.SavePath = path.Clean(dtu.OriginSaveRootPath + string(os.PathSeparator) + targetSaveRelativePath) - fmt.Printf("插件修改文件下载保存路径为: %s\n", dtu.SavePath) + dtu.logf("插件修改文件下载保存路径为: %s\n", dtu.SavePath) } } @@ -563,7 +611,7 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) { // 是否排除下载 if utils.IsExcludeFile(fileList[k].Path, &dtu.Cfg.ExcludeNames) { - fmt.Printf("排除文件: %s\n", fileList[k].Path) + dtu.logf("排除文件: %s\n", fileList[k].Path) continue } @@ -580,10 +628,14 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) { subUnit.FilePanSource = dtu.FilePanSource subUnit.FilePanPath = fileList[k].Path subUnit.SavePath = filepath.Join(dtu.OriginSaveRootPath, fileList[k].Path) // 保存位置 + subUnit.skipReason = "" // 加入父队列,按照队列调度进行下载 info := dtu.ParentTaskExecutor.Append(&subUnit, dtu.taskInfo.MaxRetry()) - fmt.Printf("[%s] 加入下载队列: %s\n", info.Id(), fileList[k].Path) + if dtu.UI != nil { + dtu.UI.RegisterTask(info.Id(), fileList[k].Path, fileList[k].FileSize, fileList[k].IsFile()) + } + dtu.logf("[%s] 加入下载队列: %s\n", info.Id(), fileList[k].Path) } // 本下载任务执行成功 @@ -591,7 +643,7 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) { return } - fmt.Printf("[%s] 准备下载: %s\n", dtu.taskInfo.Id(), dtu.FilePanPath) + dtu.logf("[%s] 准备下载: %s\n", dtu.taskInfo.Id(), dtu.FilePanPath) //if !dtu.IsOverwrite && FileExist(dtu.SavePath) { // fmt.Printf("[%s] 文件已经存在: %s, 跳过...\n", dtu.taskInfo.Id(), dtu.SavePath) @@ -600,12 +652,14 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) { //} // 支持符号文件,逻辑和注释代码一致 if !dtu.IsOverwrite && SymlinkFileExist(dtu.SavePath, dtu.OriginSaveRootPath) { - fmt.Printf("[%s] 文件已经存在: %s, 跳过...\n", dtu.taskInfo.Id(), dtu.SavePath) + dtu.skipReason = "文件已存在,跳过: " + dtu.SavePath + dtu.setUIState(ui.TaskSkipped, dtu.skipReason) + dtu.logf("[%s] 文件已经存在: %s, 跳过...\n", dtu.taskInfo.Id(), dtu.SavePath) result.Succeed = true // 执行成功 return } - fmt.Printf("[%s] 将会下载到路径: %s\n", dtu.taskInfo.Id(), dtu.SavePath) + dtu.logf("[%s] 将会下载到路径: %s\n", dtu.taskInfo.Id(), dtu.SavePath) var ok bool er := dtu.download() diff --git a/internal/ui/download_dashboard.go b/internal/ui/download_dashboard.go new file mode 100644 index 0000000..9cfca37 --- /dev/null +++ b/internal/ui/download_dashboard.go @@ -0,0 +1,771 @@ +// Copyright (c) 2026. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package ui + +import ( + "fmt" + "io" + "math" + "os" + "strings" + "sync" + "time" + + "github.com/tickstep/library-go/converter" + "github.com/tickstep/library-go/requester/rio/speeds" +) + +type TaskState int + +const ( + TaskQueued TaskState = iota + TaskRunning + TaskSuccess + TaskFailed + TaskSkipped + TaskCanceled +) + +type DownloadDashboardOptions struct { + Title string + MaxHistory int + ActiveSlots int + Output io.Writer +} + +type DownloadDashboard struct { + mu sync.Mutex + tasks map[string]*downloadTask + order []string + history []string + maxHistory int + parallel int + activeSlots int + globalSpeeds *speeds.Speeds + title string + startTime time.Time + renderEvery time.Duration + stopCh chan struct{} + doneCh chan struct{} + startOnce sync.Once + closeOnce sync.Once + out io.Writer +} + +type downloadTask struct { + id string + path string + name string + total int64 + downloaded int64 + speed int64 + eta time.Duration + state TaskState + message string + isFile bool + hasType bool +} + +func NewDownloadDashboard(parallel int, globalSpeeds *speeds.Speeds, opts *DownloadDashboardOptions) *DownloadDashboard { + if parallel < 1 { + parallel = 1 + } + db := &DownloadDashboard{ + tasks: make(map[string]*downloadTask), + order: make([]string, 0, 128), + history: make([]string, 0, 64), + maxHistory: 8, + parallel: parallel, + activeSlots: 5, + globalSpeeds: globalSpeeds, + title: "AliyunPan Download", + renderEvery: 500 * time.Millisecond, + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + out: os.Stdout, + } + if opts != nil { + if opts.Title != "" { + db.title = opts.Title + } + if opts.MaxHistory > 0 { + db.maxHistory = opts.MaxHistory + } + if opts.ActiveSlots > 0 { + db.activeSlots = opts.ActiveSlots + } + if opts.Output != nil { + db.out = opts.Output + } + } + if db.activeSlots < 1 { + db.activeSlots = 1 + } + return db +} + +func (db *DownloadDashboard) Start() { + if db == nil { + return + } + db.startOnce.Do(func() { + db.startTime = time.Now() + enableVirtualTerminalProcessing() + fmt.Fprint(db.out, "\x1b[?25l") + db.render() + ticker := time.NewTicker(db.renderEvery) + go func() { + defer close(db.doneCh) + defer ticker.Stop() + for { + select { + case <-db.stopCh: + db.render() + return + case <-ticker.C: + db.render() + } + } + }() + }) +} + +func (db *DownloadDashboard) Close() { + if db == nil { + return + } + db.closeOnce.Do(func() { + close(db.stopCh) + <-db.doneCh + fmt.Fprint(db.out, "\x1b[?25h") + }) +} + +func (db *DownloadDashboard) RegisterTask(id, path string, total int64, isFile bool) { + if db == nil || id == "" { + return + } + db.mu.Lock() + defer db.mu.Unlock() + task := db.getOrCreateTaskLocked(id) + if path != "" { + task.path = path + } + if task.name == "" { + task.name = baseName(path) + } + if total > 0 { + task.total = total + } + task.hasType = true + task.isFile = isFile +} + +func (db *DownloadDashboard) UpdateTaskInfo(id, name string, total int64, isFile bool) { + if db == nil || id == "" { + return + } + db.mu.Lock() + defer db.mu.Unlock() + task := db.getOrCreateTaskLocked(id) + if name != "" { + task.name = name + } + if total > 0 { + task.total = total + } + task.hasType = true + task.isFile = isFile +} + +func (db *DownloadDashboard) UpdateTaskProgress(id string, downloaded, total, speed int64, eta time.Duration) { + if db == nil || id == "" { + return + } + db.mu.Lock() + defer db.mu.Unlock() + task := db.getOrCreateTaskLocked(id) + task.downloaded = downloaded + if total > 0 { + task.total = total + } + task.speed = speed + task.eta = eta +} + +func (db *DownloadDashboard) MarkTaskState(id string, state TaskState, message string) { + if db == nil || id == "" { + return + } + db.mu.Lock() + defer db.mu.Unlock() + task := db.getOrCreateTaskLocked(id) + task.state = state + if message != "" { + task.message = message + } + switch state { + case TaskSuccess, TaskFailed, TaskSkipped, TaskCanceled: + db.appendHistoryLocked(db.defaultHistoryMessage(task, message)) + } +} + +func (db *DownloadDashboard) Logf(format string, a ...interface{}) { + if db == nil { + return + } + msg := fmt.Sprintf(format, a...) + msg = strings.TrimRight(msg, "\r\n") + if msg == "" { + return + } + db.mu.Lock() + defer db.mu.Unlock() + db.appendHistoryLocked(msg) +} + +func (db *DownloadDashboard) getOrCreateTaskLocked(id string) *downloadTask { + if task, ok := db.tasks[id]; ok { + return task + } + task := &downloadTask{ + id: id, + state: TaskQueued, + } + db.tasks[id] = task + db.order = append(db.order, id) + return task +} + +func (db *DownloadDashboard) appendHistoryLocked(message string) { + if message == "" { + return + } + timestamp := time.Now().Format("15:04:05") + db.history = append(db.history, fmt.Sprintf("[%s] %s", timestamp, message)) + if len(db.history) > db.maxHistory*3 { + db.history = db.history[len(db.history)-db.maxHistory*2:] + } +} + +func (db *DownloadDashboard) defaultHistoryMessage(task *downloadTask, message string) string { + if message != "" { + return message + } + name := task.path + if name == "" { + name = task.name + } + switch task.state { + case TaskSuccess: + return "完成: " + name + case TaskFailed: + return "失败: " + name + case TaskSkipped: + return "跳过: " + name + case TaskCanceled: + return "取消: " + name + default: + return name + } +} + +func (db *DownloadDashboard) render() { + if db == nil { + return + } + width, height := terminalSize() + if width < 60 { + width = 60 + } + innerWidth := width - 2 + snapshot := db.snapshot() + + headerLines := db.headerLines(snapshot, innerWidth) + activeLines := db.activeLines(snapshot, innerWidth) + activeTitle := db.activeTitle(snapshot) + + headerHeight := len(headerLines) + 2 + activeHeight := len(activeLines) + 4 + historyBase := 4 + minHistory := 1 + minHeight := headerHeight + activeHeight + historyBase + minHistory + if height < minHeight { + height = minHeight + } + historyLines := height - (headerHeight + activeHeight + historyBase) + if historyLines < minHistory { + historyLines = minHistory + } + historyBody := db.historyLines(snapshot, innerWidth, historyLines) + + builder := &strings.Builder{} + builder.WriteString("\x1b[H\x1b[2J") + builder.WriteString(renderBox(headerLines, width)) + builder.WriteString("\n") + builder.WriteString(renderTitledBox(activeTitle, activeLines, width)) + builder.WriteString("\n") + builder.WriteString(renderTitledBox("History", historyBody, width)) + + fmt.Fprint(db.out, builder.String()) +} + +type dashboardSnapshot struct { + tasks []*downloadTask + history []string +} + +func (db *DownloadDashboard) snapshot() dashboardSnapshot { + db.mu.Lock() + defer db.mu.Unlock() + tasks := make([]*downloadTask, 0, len(db.order)) + for _, id := range db.order { + if task, ok := db.tasks[id]; ok { + copied := *task + tasks = append(tasks, &copied) + } + } + history := append([]string(nil), db.history...) + return dashboardSnapshot{tasks: tasks, history: history} +} + +func renderBox(lines []string, width int) string { + if width < 4 { + width = 4 + } + innerWidth := width - 2 + builder := &strings.Builder{} + builder.WriteString("\u250c") + builder.WriteString(strings.Repeat("\u2500", innerWidth)) + builder.WriteString("\u2510") + for _, line := range lines { + builder.WriteString("\n") + builder.WriteString("\u2502") + builder.WriteString(fitLine(line, innerWidth)) + builder.WriteString("\u2502") + } + builder.WriteString("\n") + builder.WriteString("\u2514") + builder.WriteString(strings.Repeat("\u2500", innerWidth)) + builder.WriteString("\u2518") + return builder.String() +} + +func renderTitledBox(title string, body []string, width int) string { + if width < 4 { + width = 4 + } + innerWidth := width - 2 + builder := &strings.Builder{} + builder.WriteString("\u250c") + builder.WriteString(strings.Repeat("\u2500", innerWidth)) + builder.WriteString("\u2510\n") + builder.WriteString("\u2502") + builder.WriteString(fitLine(title, innerWidth)) + builder.WriteString("\u2502\n") + builder.WriteString("\u251c") + builder.WriteString(strings.Repeat("\u2500", innerWidth)) + builder.WriteString("\u2524") + for _, line := range body { + builder.WriteString("\n") + builder.WriteString("\u2502") + builder.WriteString(fitLine(line, innerWidth)) + builder.WriteString("\u2502") + } + builder.WriteString("\n") + builder.WriteString("\u2514") + builder.WriteString(strings.Repeat("\u2500", innerWidth)) + builder.WriteString("\u2518") + return builder.String() +} + +func (db *DownloadDashboard) headerLines(snapshot dashboardSnapshot, innerWidth int) []string { + totalFiles, doneFiles, failedFiles := db.fileCounts(snapshot.tasks) + downloaded, total := db.totalProgress(snapshot.tasks) + speed := db.totalSpeed(snapshot.tasks) + elapsed := time.Since(db.startTime) + left := "-" + if speed > 0 && total > downloaded { + left = formatDurationShort(time.Duration((total-downloaded)/speed) * time.Second) + } + + status := "Running" + if totalFiles > 0 && doneFiles >= totalFiles { + if failedFiles > 0 { + status = "Completed (errors)" + } else { + status = "Completed" + } + } + + headerLine := fmt.Sprintf("%s [%s]", db.title, status) + line1 := fitLine(headerLine, innerWidth) + + speedStr := converter.ConvertFileSize(speed, 2) + "/s" + line2 := fmt.Sprintf("Total Speed: %s | Files: %d/%d | Failed: %d | Elapsed: %s | Left: %s", + speedStr, doneFiles, totalFiles, failedFiles, formatDurationShort(elapsed), left) + line2 = fitLine(line2, innerWidth) + + progressPct := 0.0 + if total > 0 { + progressPct = float64(downloaded) / float64(total) + } + progress := fmt.Sprintf("%s/%s", converter.ConvertFileSize(downloaded, 2), converter.ConvertFileSize(total, 2)) + label := "Total Progress: " + suffix := fmt.Sprintf(" %5.1f%% (%s)", progressPct*100, progress) + barWidth := innerWidth - displayWidth(label) - displayWidth(suffix) - 2 + if barWidth < 10 { + barWidth = 10 + } + bar := renderBar(progressPct, barWidth) + line3 := fmt.Sprintf("%s[%s]%s", label, bar, suffix) + line3 = fitLine(line3, innerWidth) + + return []string{line1, line2, line3} +} + +func (db *DownloadDashboard) activeTitle(snapshot dashboardSnapshot) string { + running := 0 + queued := 0 + for _, task := range snapshot.tasks { + if task.hasType && !task.isFile { + continue + } + switch task.state { + case TaskRunning: + running++ + case TaskQueued: + queued++ + } + } + return fmt.Sprintf("Active Downloads (slots %d, running %d, queued %d)", db.activeSlots, running, queued) +} + +func (db *DownloadDashboard) activeLines(snapshot dashboardSnapshot, innerWidth int) []string { + active := db.pickActiveTasks(snapshot.tasks) + lines := make([]string, 0, db.activeSlots) + for i := 0; i < db.activeSlots; i++ { + if i < len(active) { + lines = append(lines, renderTaskLine(active[i], i, innerWidth)) + continue + } + placeholder := &downloadTask{name: "waiting...", state: TaskQueued} + lines = append(lines, renderTaskLine(placeholder, i, innerWidth)) + } + return lines +} + +func (db *DownloadDashboard) historyLines(snapshot dashboardSnapshot, innerWidth int, lines int) []string { + if lines < 1 { + lines = 1 + } + maxLines := lines + if db.maxHistory > 0 && maxLines > db.maxHistory { + maxLines = db.maxHistory + } + if maxLines > len(snapshot.history) { + maxLines = len(snapshot.history) + } + result := make([]string, 0, lines) + start := len(snapshot.history) - maxLines + if start < 0 { + start = 0 + } + for i := len(snapshot.history) - 1; i >= start && len(result) < maxLines; i-- { + result = append(result, truncateText(snapshot.history[i], innerWidth)) + } + if len(result) == 0 { + result = append(result, truncateText("No recent events.", innerWidth)) + } + for len(result) < lines { + result = append(result, "") + } + return result[:lines] +} + +func (db *DownloadDashboard) fileCounts(tasks []*downloadTask) (total int, done int, failed int) { + for _, task := range tasks { + if task.hasType && !task.isFile { + continue + } + total++ + switch task.state { + case TaskSuccess, TaskSkipped, TaskFailed, TaskCanceled: + done++ + } + if task.state == TaskFailed { + failed++ + } + } + return +} + +func (db *DownloadDashboard) totalProgress(tasks []*downloadTask) (downloaded int64, total int64) { + for _, task := range tasks { + if task.hasType && !task.isFile { + continue + } + if task.total > 0 { + total += task.total + if task.downloaded > task.total { + downloaded += task.total + } else { + downloaded += task.downloaded + } + } + } + return +} + +func (db *DownloadDashboard) totalSpeed(tasks []*downloadTask) int64 { + if db.globalSpeeds != nil { + return db.globalSpeeds.GetSpeeds() + } + var sum int64 + for _, task := range tasks { + sum += task.speed + } + return sum +} + +func (db *DownloadDashboard) pickActiveTasks(tasks []*downloadTask) []*downloadTask { + slotCap := db.activeSlots + if slotCap < 1 { + slotCap = 1 + } + active := make([]*downloadTask, 0, slotCap) + queued := make([]*downloadTask, 0, slotCap) + for _, task := range tasks { + if task.hasType && !task.isFile { + continue + } + switch task.state { + case TaskRunning: + active = append(active, task) + case TaskQueued: + queued = append(queued, task) + } + } + active = append(active, queued...) + if len(active) > slotCap { + active = active[:slotCap] + } + return active +} + +func renderTaskLine(task *downloadTask, index int, width int) string { + if width < 20 { + width = 20 + } + prefix := fmt.Sprintf("%2d. ", index+1) + name := task.name + if name == "" { + name = baseName(task.path) + } + + progressPct := 0.0 + percent := "--.-%" + if task.total > 0 { + progressPct = float64(task.downloaded) / float64(task.total) + percent = fmt.Sprintf("%5.1f%%", math.Min(100, progressPct*100)) + } + + rate := "-" + eta := "-" + switch task.state { + case TaskQueued: + rate = "waiting" + case TaskRunning: + if task.speed > 0 { + rate = converter.ConvertFileSize(task.speed, 2) + "/s" + } else { + rate = "0 B/s" + } + if task.eta > 0 { + eta = formatDurationShort(task.eta) + } + case TaskSuccess: + rate = "done" + case TaskFailed: + rate = "failed" + case TaskSkipped: + rate = "skipped" + case TaskCanceled: + rate = "canceled" + } + + barWidth := 20 + rateWidth := 12 + etaWidth := 8 + percentWidth := displayWidth(percent) + fixed := displayWidth(prefix) + 1 + 2 + barWidth + 1 + percentWidth + 1 + rateWidth + 1 + etaWidth + nameWidth := width - fixed + if nameWidth < 8 { + reduce := 8 - nameWidth + if barWidth > 10 { + cut := minInt(reduce, barWidth-10) + barWidth -= cut + reduce -= cut + } + if rateWidth > 8 && reduce > 0 { + cut := minInt(reduce, rateWidth-8) + rateWidth -= cut + reduce -= cut + } + if etaWidth > 5 && reduce > 0 { + cut := minInt(reduce, etaWidth-5) + etaWidth -= cut + reduce -= cut + } + fixed = displayWidth(prefix) + 1 + 2 + barWidth + 1 + percentWidth + 1 + rateWidth + 1 + etaWidth + nameWidth = width - fixed + if nameWidth < 4 { + nameWidth = 4 + } + } + + name = padRight(truncateText(name, nameWidth), nameWidth) + bar := renderBar(progressPct, barWidth) + rate = padRight(truncateText(rate, rateWidth), rateWidth) + eta = padRight(truncateText(eta, etaWidth), etaWidth) + line := fmt.Sprintf("%s%s [%s] %s %s %s", prefix, name, bar, percent, rate, eta) + return fitLine(line, width) +} + +func renderBar(pct float64, width int) string { + if width < 5 { + width = 5 + } + if pct < 0 { + pct = 0 + } + if pct > 1 { + pct = 1 + } + filled := int(math.Floor(pct * float64(width))) + return strings.Repeat("\u2588", filled) + strings.Repeat("\u2591", width-filled) +} + +func formatDurationShort(d time.Duration) string { + if d <= 0 { + return "-" + } + seconds := int(d.Seconds()) + if seconds < 60 { + return fmt.Sprintf("%ds", seconds) + } + minutes := seconds / 60 + seconds = seconds % 60 + if minutes < 60 { + return fmt.Sprintf("%dm%02ds", minutes, seconds) + } + hours := minutes / 60 + minutes = minutes % 60 + if hours < 100 { + return fmt.Sprintf("%dh%02dm", hours, minutes) + } + return fmt.Sprintf("%dh", hours) +} + +func truncateText(text string, max int) string { + if max <= 0 || text == "" { + return "" + } + if displayWidth(text) <= max { + return text + } + if max <= 3 { + return takeWidth(text, max) + } + return takeWidth(text, max-3) + "..." +} + +func padRight(text string, width int) string { + if width <= 0 { + return text + } + if displayWidth(text) >= width { + return text + } + return text + strings.Repeat(" ", width-displayWidth(text)) +} + +func fitLine(text string, width int) string { + return padRight(truncateText(text, width), width) +} + +func displayWidth(text string) int { + width := 0 + for _, r := range text { + width += runeWidth(r) + } + return width +} + +func takeWidth(text string, max int) string { + if max <= 0 { + return "" + } + used := 0 + for idx, r := range text { + w := runeWidth(r) + if used+w > max { + return text[:idx] + } + used += w + } + return text +} + +func runeWidth(r rune) int { + if r == 0 { + return 0 + } + if r < 0x1100 { + return 1 + } + switch { + case r >= 0x1100 && r <= 0x115f, + r == 0x2329 || r == 0x232a, + r >= 0x2e80 && r <= 0xa4cf && r != 0x303f, + r >= 0xac00 && r <= 0xd7a3, + r >= 0xf900 && r <= 0xfaff, + r >= 0xfe10 && r <= 0xfe19, + r >= 0xfe30 && r <= 0xfe6f, + r >= 0xff00 && r <= 0xff60, + r >= 0xffe0 && r <= 0xffe6, + r >= 0x20000 && r <= 0x2fffd, + r >= 0x30000 && r <= 0x3fffd: + return 2 + } + return 1 +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func baseName(path string) string { + if path == "" { + return "" + } + path = strings.ReplaceAll(path, "\\", "/") + if idx := strings.LastIndex(path, "/"); idx >= 0 { + return path[idx+1:] + } + return path +} diff --git a/internal/ui/terminal.go b/internal/ui/terminal.go new file mode 100644 index 0000000..e85b1ff --- /dev/null +++ b/internal/ui/terminal.go @@ -0,0 +1,27 @@ +// Copyright (c) 2026. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package ui + +import "os" + +func IsTerminal(file *os.File) bool { + if file == nil { + return false + } + fi, err := file.Stat() + if err != nil { + return false + } + return (fi.Mode() & os.ModeCharDevice) != 0 +} diff --git a/internal/ui/terminal_unix.go b/internal/ui/terminal_unix.go new file mode 100644 index 0000000..2d05855 --- /dev/null +++ b/internal/ui/terminal_unix.go @@ -0,0 +1,32 @@ +//go:build !windows + +// Copyright (c) 2026. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package ui + +import ( + "os" + + "golang.org/x/sys/unix" +) + +func terminalSize() (int, int) { + ws, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ) + if err != nil || ws == nil || ws.Col == 0 || ws.Row == 0 { + return 80, 24 + } + return int(ws.Col), int(ws.Row) +} + +func enableVirtualTerminalProcessing() {} diff --git a/internal/ui/terminal_windows.go b/internal/ui/terminal_windows.go new file mode 100644 index 0000000..e8fd03f --- /dev/null +++ b/internal/ui/terminal_windows.go @@ -0,0 +1,46 @@ +//go:build windows + +// Copyright (c) 2026. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +package ui + +import ( + "os" + + "golang.org/x/sys/windows" +) + +func terminalSize() (int, int) { + handle := windows.Handle(os.Stdout.Fd()) + var info windows.ConsoleScreenBufferInfo + if err := windows.GetConsoleScreenBufferInfo(handle, &info); err != nil { + return 80, 24 + } + width := int(info.Window.Right - info.Window.Left + 1) + height := int(info.Window.Bottom - info.Window.Top + 1) + if width <= 0 || height <= 0 { + return 80, 24 + } + return width, height +} + +func enableVirtualTerminalProcessing() { + handle := windows.Handle(os.Stdout.Fd()) + var mode uint32 + if err := windows.GetConsoleMode(handle, &mode); err != nil { + return + } + mode |= windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING + _ = windows.SetConsoleMode(handle, mode) +}