mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-05-02 02:22:06 -04:00
dock: add trash CLI, refine implementation
This commit is contained in:
@@ -526,5 +526,6 @@ func getCommonCommands() []*cobra.Command {
|
|||||||
dlCmd,
|
dlCmd,
|
||||||
randrCmd,
|
randrCmd,
|
||||||
blurCmd,
|
blurCmd,
|
||||||
|
trashCmd,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
122
core/cmd/dms/commands_trash.go
Normal file
122
core/cmd/dms/commands_trash.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/trash"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var trashCmd = &cobra.Command{
|
||||||
|
Use: "trash",
|
||||||
|
Short: "Manage the user's trash (XDG Trash spec 1.0)",
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashPutCmd = &cobra.Command{
|
||||||
|
Use: "put <path...>",
|
||||||
|
Short: "Move files or directories into the trash",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
Run: runTrashPut,
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List trashed items across all known trash directories",
|
||||||
|
Run: runTrashList,
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashCountCmd = &cobra.Command{
|
||||||
|
Use: "count",
|
||||||
|
Short: "Print the total number of trashed items",
|
||||||
|
Run: runTrashCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashEmptyCmd = &cobra.Command{
|
||||||
|
Use: "empty",
|
||||||
|
Short: "Permanently delete every trashed item",
|
||||||
|
Run: runTrashEmpty,
|
||||||
|
}
|
||||||
|
|
||||||
|
var trashRestoreCmd = &cobra.Command{
|
||||||
|
Use: "restore <name>",
|
||||||
|
Short: "Restore a trashed item to its original location",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: runTrashRestore,
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
trashJSONOutput bool
|
||||||
|
trashRestoreDir string
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
trashListCmd.Flags().BoolVar(&trashJSONOutput, "json", false, "Output as JSON")
|
||||||
|
trashRestoreCmd.Flags().StringVar(&trashRestoreDir, "trash-dir", "", "Trash directory containing the item (default: home trash)")
|
||||||
|
trashCmd.AddCommand(trashPutCmd, trashListCmd, trashCountCmd, trashEmptyCmd, trashRestoreCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashPut(cmd *cobra.Command, args []string) {
|
||||||
|
var failed int
|
||||||
|
for _, p := range args {
|
||||||
|
if _, err := trash.Put(p); err != nil {
|
||||||
|
log.Errorf("trash %s: %v", p, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Println(p)
|
||||||
|
}
|
||||||
|
if failed > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashList(cmd *cobra.Command, args []string) {
|
||||||
|
entries, err := trash.List()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("list trash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if trashJSONOutput {
|
||||||
|
if entries == nil {
|
||||||
|
entries = []trash.Entry{}
|
||||||
|
}
|
||||||
|
out, _ := json.MarshalIndent(entries, "", " ")
|
||||||
|
fmt.Println(string(out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) == 0 {
|
||||||
|
fmt.Println("Trash is empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
marker := "F"
|
||||||
|
if e.IsDir {
|
||||||
|
marker = "D"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s %s %s %s\n", marker, e.DeletionDate, e.Name, e.OriginalPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashCount(cmd *cobra.Command, args []string) {
|
||||||
|
n, err := trash.Count()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("count trash: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Println(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashEmpty(cmd *cobra.Command, args []string) {
|
||||||
|
if err := trash.Empty(); err != nil {
|
||||||
|
log.Fatalf("empty trash: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTrashRestore(cmd *cobra.Command, args []string) {
|
||||||
|
if err := trash.Restore(args[0], trashRestoreDir); err != nil {
|
||||||
|
log.Fatalf("restore: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
455
core/internal/trash/trash.go
Normal file
455
core/internal/trash/trash.go
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
// Package trash implements the FreeDesktop.org Trash specification 1.0.
|
||||||
|
// See: https://specifications.freedesktop.org/trash-spec/trashspec-1.0.html
|
||||||
|
package trash
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const trashInfoExt = ".trashinfo"
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
OriginalPath string `json:"originalPath"`
|
||||||
|
DeletionDate string `json:"deletionDate"`
|
||||||
|
TrashDir string `json:"trashDir"`
|
||||||
|
FilesPath string `json:"filesPath"`
|
||||||
|
InfoPath string `json:"infoPath"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
IsDir bool `json:"isDir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func homeTrashDir() (string, error) {
|
||||||
|
xdg := os.Getenv("XDG_DATA_HOME")
|
||||||
|
if xdg == "" {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
xdg = filepath.Join(home, ".local", "share")
|
||||||
|
}
|
||||||
|
return filepath.Join(xdg, "Trash"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureTrashDirs(trashDir string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Join(trashDir, "files"), 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.MkdirAll(filepath.Join(trashDir, "info"), 0o700)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fsDevice(path string) (uint64, error) {
|
||||||
|
var st syscall.Stat_t
|
||||||
|
if err := syscall.Lstat(path, &st); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return uint64(st.Dev), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fsDeviceWalkUp(start string) (uint64, error) {
|
||||||
|
cur := start
|
||||||
|
for {
|
||||||
|
if dev, err := fsDevice(cur); err == nil {
|
||||||
|
return dev, nil
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(cur)
|
||||||
|
if parent == cur {
|
||||||
|
return 0, fmt.Errorf("no existing ancestor for %s", start)
|
||||||
|
}
|
||||||
|
cur = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findTopDir(path string) (string, error) {
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dev, err := fsDevice(abs)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
cur := abs
|
||||||
|
for {
|
||||||
|
parent := filepath.Dir(cur)
|
||||||
|
if parent == cur {
|
||||||
|
return cur, nil
|
||||||
|
}
|
||||||
|
pdev, err := fsDevice(parent)
|
||||||
|
if err != nil {
|
||||||
|
return cur, nil
|
||||||
|
}
|
||||||
|
if pdev != dev {
|
||||||
|
return cur, nil
|
||||||
|
}
|
||||||
|
cur = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidSharedTrash enforces the spec's checks on $topdir/.Trash:
|
||||||
|
// must exist, must be a directory, must not be a symlink, must have sticky bit.
|
||||||
|
func isValidSharedTrash(p string) bool {
|
||||||
|
info, err := os.Lstat(p)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return info.Mode()&os.ModeSticky != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// trashDirForPath chooses the correct trash dir per spec and returns the value
|
||||||
|
// to store in the .trashinfo Path field (absolute for home, relative-to-topdir
|
||||||
|
// for per-mountpoint trash).
|
||||||
|
func trashDirForPath(absPath string) (trashDir string, storedPath string, err error) {
|
||||||
|
home, err := homeTrashDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
pathDev, err := fsDevice(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
homeDev, err := fsDeviceWalkUp(home)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if pathDev == homeDev {
|
||||||
|
return home, absPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
topDir, err := findTopDir(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := strconv.Itoa(os.Getuid())
|
||||||
|
stored, rerr := filepath.Rel(topDir, absPath)
|
||||||
|
if rerr != nil || strings.HasPrefix(stored, "..") {
|
||||||
|
stored = absPath
|
||||||
|
}
|
||||||
|
|
||||||
|
shared := filepath.Join(topDir, ".Trash")
|
||||||
|
if isValidSharedTrash(shared) {
|
||||||
|
return filepath.Join(shared, uid), stored, nil
|
||||||
|
}
|
||||||
|
return filepath.Join(topDir, ".Trash-"+uid), stored, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// uniqueName returns a basename in trashDir that does not collide with an
|
||||||
|
// existing entry in either files/ or info/.
|
||||||
|
func uniqueName(trashDir, basename string) (string, error) {
|
||||||
|
filesDir := filepath.Join(trashDir, "files")
|
||||||
|
infoDir := filepath.Join(trashDir, "info")
|
||||||
|
if !exists(filepath.Join(filesDir, basename)) && !exists(filepath.Join(infoDir, basename+trashInfoExt)) {
|
||||||
|
return basename, nil
|
||||||
|
}
|
||||||
|
ext := filepath.Ext(basename)
|
||||||
|
stem := strings.TrimSuffix(basename, ext)
|
||||||
|
for i := 2; i < 100000; i++ {
|
||||||
|
candidate := fmt.Sprintf("%s.%d%s", stem, i, ext)
|
||||||
|
if !exists(filepath.Join(filesDir, candidate)) && !exists(filepath.Join(infoDir, candidate+trashInfoExt)) {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New("could not find unique trash name")
|
||||||
|
}
|
||||||
|
|
||||||
|
func exists(p string) bool {
|
||||||
|
_, err := os.Lstat(p)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathEncode percent-escapes a POSIX path per RFC 2396, preserving "/".
|
||||||
|
func pathEncode(p string) string {
|
||||||
|
parts := strings.Split(p, "/")
|
||||||
|
for i, seg := range parts {
|
||||||
|
parts[i] = url.PathEscape(seg)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathDecode(p string) string {
|
||||||
|
if d, err := url.PathUnescape(p); err == nil {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTrashInfo(infoPath, storedPath string, when time.Time) error {
|
||||||
|
body := "[Trash Info]\nPath=" + pathEncode(storedPath) +
|
||||||
|
"\nDeletionDate=" + when.Format("2006-01-02T15:04:05") + "\n"
|
||||||
|
f, err := os.OpenFile(infoPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = f.WriteString(body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put trashes a single file or directory.
|
||||||
|
func Put(path string) (Entry, error) {
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
info, err := os.Lstat(abs)
|
||||||
|
if err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
trashDir, storedPath, err := trashDirForPath(abs)
|
||||||
|
if err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
if err := ensureTrashDirs(trashDir); err != nil {
|
||||||
|
return Entry{}, fmt.Errorf("create trash dir %s: %w", trashDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
name, err := uniqueName(trashDir, filepath.Base(abs))
|
||||||
|
if err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
infoPath := filepath.Join(trashDir, "info", name+trashInfoExt)
|
||||||
|
when := time.Now()
|
||||||
|
if err := writeTrashInfo(infoPath, storedPath, when); err != nil {
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
target := filepath.Join(trashDir, "files", name)
|
||||||
|
if err := os.Rename(abs, target); err != nil {
|
||||||
|
os.Remove(infoPath)
|
||||||
|
return Entry{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return Entry{
|
||||||
|
Name: name,
|
||||||
|
OriginalPath: storedPath,
|
||||||
|
DeletionDate: when.Format("2006-01-02T15:04:05"),
|
||||||
|
TrashDir: trashDir,
|
||||||
|
FilesPath: target,
|
||||||
|
InfoPath: infoPath,
|
||||||
|
Size: info.Size(),
|
||||||
|
IsDir: info.IsDir(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allTrashDirs returns the home trash plus every per-mountpoint trash dir
|
||||||
|
// that exists (and passes the spec's safety checks for $topdir/.Trash).
|
||||||
|
func allTrashDirs() []string {
|
||||||
|
var dirs []string
|
||||||
|
if h, err := homeTrashDir(); err == nil {
|
||||||
|
dirs = append(dirs, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := strconv.Itoa(os.Getuid())
|
||||||
|
for _, mount := range readMountPoints() {
|
||||||
|
shared := filepath.Join(mount, ".Trash")
|
||||||
|
if isValidSharedTrash(shared) {
|
||||||
|
candidate := filepath.Join(shared, uid)
|
||||||
|
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
||||||
|
dirs = append(dirs, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidate := filepath.Join(mount, ".Trash-"+uid)
|
||||||
|
if info, err := os.Lstat(candidate); err == nil && info.IsDir() && info.Mode()&os.ModeSymlink == 0 {
|
||||||
|
dirs = append(dirs, candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dirs
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMountPoints returns user-visible mount points from /proc/self/mountinfo,
|
||||||
|
// skipping pseudo and system filesystems.
|
||||||
|
func readMountPoints() []string {
|
||||||
|
data, err := os.ReadFile("/proc/self/mountinfo")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
skipPrefixes := []string{"/proc", "/sys", "/dev"}
|
||||||
|
var out []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for line := range strings.SplitSeq(string(data), "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 5 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mp := fields[4]
|
||||||
|
if mp == "/" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
skip := false
|
||||||
|
for _, p := range skipPrefixes {
|
||||||
|
if mp == p || strings.HasPrefix(mp, p+"/") {
|
||||||
|
skip = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if skip || seen[mp] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[mp] = true
|
||||||
|
out = append(out, mp)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func List() ([]Entry, error) {
|
||||||
|
var entries []Entry
|
||||||
|
for _, d := range allTrashDirs() {
|
||||||
|
es, _ := listOne(d)
|
||||||
|
entries = append(entries, es...)
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listOne(trashDir string) ([]Entry, error) {
|
||||||
|
infoDir := filepath.Join(trashDir, "info")
|
||||||
|
filesDir := filepath.Join(trashDir, "files")
|
||||||
|
dir, err := os.ReadDir(infoDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var entries []Entry
|
||||||
|
for _, ent := range dir {
|
||||||
|
if !strings.HasSuffix(ent.Name(), trashInfoExt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSuffix(ent.Name(), trashInfoExt)
|
||||||
|
infoPath := filepath.Join(infoDir, ent.Name())
|
||||||
|
filesPath := filepath.Join(filesDir, name)
|
||||||
|
|
||||||
|
body, err := os.ReadFile(infoPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
e := Entry{Name: name, TrashDir: trashDir, InfoPath: infoPath, FilesPath: filesPath}
|
||||||
|
for line := range strings.SplitSeq(string(body), "\n") {
|
||||||
|
if v, ok := strings.CutPrefix(line, "Path="); ok {
|
||||||
|
e.OriginalPath = pathDecode(v)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v, ok := strings.CutPrefix(line, "DeletionDate="); ok {
|
||||||
|
e.DeletionDate = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if info, err := os.Lstat(filesPath); err == nil {
|
||||||
|
e.Size = info.Size()
|
||||||
|
e.IsDir = info.IsDir()
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Count() (int, error) {
|
||||||
|
n := 0
|
||||||
|
for _, d := range allTrashDirs() {
|
||||||
|
ents, err := os.ReadDir(filepath.Join(d, "info"))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range ents {
|
||||||
|
if strings.HasSuffix(e.Name(), trashInfoExt) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Empty() error {
|
||||||
|
var firstErr error
|
||||||
|
for _, d := range allTrashDirs() {
|
||||||
|
if err := emptyOne(d); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyOne(trashDir string) error {
|
||||||
|
var firstErr error
|
||||||
|
for _, sub := range []string{"files", "info"} {
|
||||||
|
path := filepath.Join(trashDir, sub)
|
||||||
|
ents, err := os.ReadDir(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range ents {
|
||||||
|
if err := os.RemoveAll(filepath.Join(path, e.Name())); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
os.Remove(filepath.Join(trashDir, "directorysizes"))
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore returns a trashed item to its original location.
|
||||||
|
func Restore(name, trashDir string) error {
|
||||||
|
if trashDir == "" {
|
||||||
|
h, err := homeTrashDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
trashDir = h
|
||||||
|
}
|
||||||
|
|
||||||
|
infoPath := filepath.Join(trashDir, "info", name+trashInfoExt)
|
||||||
|
filesPath := filepath.Join(trashDir, "files", name)
|
||||||
|
|
||||||
|
body, err := os.ReadFile(infoPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var stored string
|
||||||
|
for line := range strings.SplitSeq(string(body), "\n") {
|
||||||
|
if v, ok := strings.CutPrefix(line, "Path="); ok {
|
||||||
|
stored = pathDecode(v)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stored == "" {
|
||||||
|
return errors.New("invalid .trashinfo: missing Path")
|
||||||
|
}
|
||||||
|
|
||||||
|
target := stored
|
||||||
|
if !filepath.IsAbs(stored) {
|
||||||
|
topDir := filepath.Dir(trashDir)
|
||||||
|
if filepath.Base(topDir) == ".Trash" {
|
||||||
|
topDir = filepath.Dir(topDir)
|
||||||
|
}
|
||||||
|
target = filepath.Join(topDir, stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists(target) {
|
||||||
|
return fmt.Errorf("restore target already exists: %s", target)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(filesPath, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
os.Remove(infoPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
315
core/internal/trash/trash_test.go
Normal file
315
core/internal/trash/trash_test.go
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
package trash
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupHomeTrash(t *testing.T) (homeRoot string, trashDir string) {
|
||||||
|
t.Helper()
|
||||||
|
homeRoot = t.TempDir()
|
||||||
|
xdg := filepath.Join(homeRoot, ".local", "share")
|
||||||
|
if err := os.MkdirAll(xdg, 0o700); err != nil {
|
||||||
|
t.Fatalf("mkdir xdg: %v", err)
|
||||||
|
}
|
||||||
|
t.Setenv("XDG_DATA_HOME", xdg)
|
||||||
|
t.Setenv("HOME", homeRoot)
|
||||||
|
trashDir = filepath.Join(xdg, "Trash")
|
||||||
|
return homeRoot, trashDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutHomeTrashAbsolutePath(t *testing.T) {
|
||||||
|
homeRoot, trashDir := setupHomeTrash(t)
|
||||||
|
|
||||||
|
src := filepath.Join(homeRoot, "doc.txt")
|
||||||
|
writeFile(t, src, "hi")
|
||||||
|
|
||||||
|
entry, err := Put(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.Name != "doc.txt" {
|
||||||
|
t.Errorf("name = %q, want doc.txt", entry.Name)
|
||||||
|
}
|
||||||
|
if entry.OriginalPath != src {
|
||||||
|
t.Errorf("originalPath = %q, want %q", entry.OriginalPath, src)
|
||||||
|
}
|
||||||
|
if entry.TrashDir != trashDir {
|
||||||
|
t.Errorf("trashDir = %q, want %q", entry.TrashDir, trashDir)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(src); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("source still exists: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := os.ReadFile(filepath.Join(trashDir, "info", "doc.txt.trashinfo"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read trashinfo: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(string(body), "[Trash Info]\n") {
|
||||||
|
t.Errorf("trashinfo missing header: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "Path="+src+"\n") {
|
||||||
|
t.Errorf("Path key missing or wrong: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(body), "DeletionDate=") {
|
||||||
|
t.Errorf("DeletionDate missing: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutPercentEncodesPath(t *testing.T) {
|
||||||
|
homeRoot, trashDir := setupHomeTrash(t)
|
||||||
|
|
||||||
|
name := "spaces & %.txt"
|
||||||
|
src := filepath.Join(homeRoot, name)
|
||||||
|
writeFile(t, src, "x")
|
||||||
|
|
||||||
|
if _, err := Put(src); err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := os.ReadFile(filepath.Join(trashDir, "info", name+".trashinfo"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read: %v", err)
|
||||||
|
}
|
||||||
|
want := "Path=" + filepath.Dir(src) + "/spaces%20&%20%25.txt"
|
||||||
|
if !strings.Contains(string(body), want) {
|
||||||
|
t.Errorf("expected %q in %q", want, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutCollisionGetsUniqueName(t *testing.T) {
|
||||||
|
homeRoot, trashDir := setupHomeTrash(t)
|
||||||
|
|
||||||
|
for i := range 3 {
|
||||||
|
src := filepath.Join(homeRoot, "dup.txt")
|
||||||
|
writeFile(t, src, "x")
|
||||||
|
if _, err := Put(src); err != nil {
|
||||||
|
t.Fatalf("Put #%d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"dup.txt", "dup.2.txt", "dup.3.txt"}
|
||||||
|
for _, n := range want {
|
||||||
|
if _, err := os.Stat(filepath.Join(trashDir, "files", n)); err != nil {
|
||||||
|
t.Errorf("expected %s in trash: %v", n, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(trashDir, "info", n+".trashinfo")); err != nil {
|
||||||
|
t.Errorf("expected %s.trashinfo: %v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAndCount(t *testing.T) {
|
||||||
|
homeRoot, _ := setupHomeTrash(t)
|
||||||
|
|
||||||
|
if n, _ := Count(); n != 0 {
|
||||||
|
t.Errorf("initial count = %d, want 0", n)
|
||||||
|
}
|
||||||
|
entries, _ := List()
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Errorf("initial list len = %d, want 0", len(entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range []string{"a.txt", "b.txt", "c.log"} {
|
||||||
|
src := filepath.Join(homeRoot, n)
|
||||||
|
writeFile(t, src, n)
|
||||||
|
if _, err := Put(src); err != nil {
|
||||||
|
t.Fatalf("Put %s: %v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
got, _ := Count()
|
||||||
|
if got != 3 {
|
||||||
|
t.Errorf("count = %d, want 3", got)
|
||||||
|
}
|
||||||
|
entries, _ = List()
|
||||||
|
if len(entries) != 3 {
|
||||||
|
t.Errorf("list len = %d, want 3", len(entries))
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.OriginalPath == "" {
|
||||||
|
t.Errorf("entry %s: empty OriginalPath", e.Name)
|
||||||
|
}
|
||||||
|
if _, err := time.Parse("2006-01-02T15:04:05", e.DeletionDate); err != nil {
|
||||||
|
t.Errorf("entry %s: bad DeletionDate %q: %v", e.Name, e.DeletionDate, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyClearsAll(t *testing.T) {
|
||||||
|
homeRoot, trashDir := setupHomeTrash(t)
|
||||||
|
|
||||||
|
for _, n := range []string{"x", "y", "z"} {
|
||||||
|
src := filepath.Join(homeRoot, n)
|
||||||
|
writeFile(t, src, n)
|
||||||
|
if _, err := Put(src); err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n, _ := Count(); n != 3 {
|
||||||
|
t.Fatalf("pre-empty count = %d", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := Empty(); err != nil {
|
||||||
|
t.Fatalf("Empty: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n, _ := Count(); n != 0 {
|
||||||
|
t.Errorf("post-empty count = %d, want 0", n)
|
||||||
|
}
|
||||||
|
for _, sub := range []string{"files", "info"} {
|
||||||
|
ents, err := os.ReadDir(filepath.Join(trashDir, sub))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("readdir %s: %v", sub, err)
|
||||||
|
}
|
||||||
|
if len(ents) != 0 {
|
||||||
|
t.Errorf("%s/ has %d entries, want 0", sub, len(ents))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreToOriginalPath(t *testing.T) {
|
||||||
|
homeRoot, trashDir := setupHomeTrash(t)
|
||||||
|
|
||||||
|
src := filepath.Join(homeRoot, "sub", "dir", "thing.txt")
|
||||||
|
writeFile(t, src, "payload")
|
||||||
|
|
||||||
|
entry, err := Put(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
os.RemoveAll(filepath.Join(homeRoot, "sub"))
|
||||||
|
|
||||||
|
if err := Restore(entry.Name, trashDir); err != nil {
|
||||||
|
t.Fatalf("Restore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := os.ReadFile(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read restored: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != "payload" {
|
||||||
|
t.Errorf("restored content = %q, want %q", body, "payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(entry.InfoPath); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("info file still present: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(entry.FilesPath); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("files entry still present: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreRefusesToOverwrite(t *testing.T) {
|
||||||
|
homeRoot, trashDir := setupHomeTrash(t)
|
||||||
|
|
||||||
|
src := filepath.Join(homeRoot, "keep.txt")
|
||||||
|
writeFile(t, src, "v1")
|
||||||
|
|
||||||
|
entry, err := Put(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
writeFile(t, src, "v2-blocking")
|
||||||
|
|
||||||
|
err = Restore(entry.Name, trashDir)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error on conflicting restore, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "exists") {
|
||||||
|
t.Errorf("error %q does not mention conflict", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, _ := os.ReadFile(src)
|
||||||
|
if string(body) != "v2-blocking" {
|
||||||
|
t.Errorf("blocking file altered: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutDirectory(t *testing.T) {
|
||||||
|
homeRoot, trashDir := setupHomeTrash(t)
|
||||||
|
|
||||||
|
dir := filepath.Join(homeRoot, "myfolder")
|
||||||
|
writeFile(t, filepath.Join(dir, "child.txt"), "inside")
|
||||||
|
|
||||||
|
entry, err := Put(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put dir: %v", err)
|
||||||
|
}
|
||||||
|
if !entry.IsDir {
|
||||||
|
t.Errorf("IsDir = false, want true")
|
||||||
|
}
|
||||||
|
|
||||||
|
moved := filepath.Join(trashDir, "files", "myfolder", "child.txt")
|
||||||
|
body, err := os.ReadFile(moved)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read moved child: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != "inside" {
|
||||||
|
t.Errorf("child content = %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidSharedTrashRejectsSymlink(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
target := filepath.Join(tmp, "real")
|
||||||
|
if err := os.MkdirAll(target, os.ModeSticky|0o777); err != nil {
|
||||||
|
t.Fatalf("mkdir target: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
link := filepath.Join(tmp, ".Trash")
|
||||||
|
if err := os.Symlink(target, link); err != nil {
|
||||||
|
t.Fatalf("symlink: %v", err)
|
||||||
|
}
|
||||||
|
if isValidSharedTrash(link) {
|
||||||
|
t.Errorf("symlinked .Trash accepted; spec requires rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidSharedTrashRequiresStickyBit(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
dir := filepath.Join(tmp, ".Trash")
|
||||||
|
if err := os.MkdirAll(dir, 0o777); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if isValidSharedTrash(dir) {
|
||||||
|
t.Errorf(".Trash without sticky bit accepted; spec requires rejection")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chmod(dir, os.ModeSticky|0o777); err != nil {
|
||||||
|
t.Fatalf("chmod: %v", err)
|
||||||
|
}
|
||||||
|
if !isValidSharedTrash(dir) {
|
||||||
|
t.Errorf(".Trash with sticky bit rejected; spec accepts it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPathEncodeRoundTrip(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"/home/u/file.txt",
|
||||||
|
"/path with spaces/and-symbols & %.txt",
|
||||||
|
"relative/path/é unicode.md",
|
||||||
|
}
|
||||||
|
for _, in := range cases {
|
||||||
|
got := pathDecode(pathEncode(in))
|
||||||
|
if got != in {
|
||||||
|
t.Errorf("round-trip %q -> %q", in, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -546,7 +546,7 @@ Singleton {
|
|||||||
property int dockMaxVisibleRunningApps: 0
|
property int dockMaxVisibleRunningApps: 0
|
||||||
property bool dockShowOverflowBadge: true
|
property bool dockShowOverflowBadge: true
|
||||||
property bool dockShowTrash: false
|
property bool dockShowTrash: false
|
||||||
property string dockTrashFileManager: "nautilus"
|
property string dockTrashFileManager: "default"
|
||||||
property string dockTrashCustomCommand: ""
|
property string dockTrashCustomCommand: ""
|
||||||
|
|
||||||
property bool notificationOverlayEnabled: false
|
property bool notificationOverlayEnabled: false
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ var SPEC = {
|
|||||||
dockMaxVisibleRunningApps: { def: 0 },
|
dockMaxVisibleRunningApps: { def: 0 },
|
||||||
dockShowOverflowBadge: { def: true },
|
dockShowOverflowBadge: { def: true },
|
||||||
dockShowTrash: { def: false },
|
dockShowTrash: { def: false },
|
||||||
dockTrashFileManager: { def: "nautilus" },
|
dockTrashFileManager: { def: "default" },
|
||||||
dockTrashCustomCommand: { def: "" },
|
dockTrashCustomCommand: { def: "" },
|
||||||
|
|
||||||
notificationOverlayEnabled: { def: false },
|
notificationOverlayEnabled: { def: false },
|
||||||
|
|||||||
@@ -158,6 +158,13 @@ FocusScope {
|
|||||||
selectedFileIsDir = isDir;
|
selectedFileIsDir = isDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openItemContextMenu(sender, localX, localY, path, name, isDir) {
|
||||||
|
if (!sender)
|
||||||
|
return;
|
||||||
|
const pos = sender.mapToItem(root, localX, localY);
|
||||||
|
itemContextMenu.showAt(root, pos.x, pos.y, path, name, isDir);
|
||||||
|
}
|
||||||
|
|
||||||
function navigateUp() {
|
function navigateUp() {
|
||||||
const path = currentPath;
|
const path = currentPath;
|
||||||
if (path === homeDir)
|
if (path === homeDir)
|
||||||
@@ -759,6 +766,9 @@ FocusScope {
|
|||||||
onItemSelected: (index, path, name, isDir) => {
|
onItemSelected: (index, path, name, isDir) => {
|
||||||
setSelectedFileData(path, name, isDir);
|
setSelectedFileData(path, name, isDir);
|
||||||
}
|
}
|
||||||
|
onItemContextMenuRequested: (sender, localX, localY, path, name, isDir) => {
|
||||||
|
root.openItemContextMenu(sender, localX, localY, path, name, isDir);
|
||||||
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
function onKeyboardSelectionRequestedChanged() {
|
function onKeyboardSelectionRequestedChanged() {
|
||||||
@@ -817,6 +827,9 @@ FocusScope {
|
|||||||
onItemSelected: (index, path, name, isDir) => {
|
onItemSelected: (index, path, name, isDir) => {
|
||||||
setSelectedFileData(path, name, isDir);
|
setSelectedFileData(path, name, isDir);
|
||||||
}
|
}
|
||||||
|
onItemContextMenuRequested: (sender, localX, localY, path, name, isDir) => {
|
||||||
|
root.openItemContextMenu(sender, localX, localY, path, name, isDir);
|
||||||
|
}
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
function onKeyboardSelectionRequestedChanged() {
|
function onKeyboardSelectionRequestedChanged() {
|
||||||
@@ -917,4 +930,9 @@ FocusScope {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FileBrowserItemContextMenu {
|
||||||
|
id: itemContextMenu
|
||||||
|
parentFocusItem: root
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ StyledRect {
|
|||||||
|
|
||||||
signal itemClicked(int index, string path, string name, bool isDir)
|
signal itemClicked(int index, string path, string name, bool isDir)
|
||||||
signal itemSelected(int index, string path, string name, bool isDir)
|
signal itemSelected(int index, string path, string name, bool isDir)
|
||||||
|
signal itemContextMenuRequested(var sender, real localX, real localY, string path, string name, bool isDir)
|
||||||
|
|
||||||
function getFileExtension(fileName) {
|
function getFileExtension(fileName) {
|
||||||
const parts = fileName.split('.');
|
const parts = fileName.split('.');
|
||||||
@@ -107,11 +108,11 @@ StyledRect {
|
|||||||
const size = _thumbnailPx;
|
const size = _thumbnailPx;
|
||||||
const fp = delegateRoot.filePath;
|
const fp = delegateRoot.filePath;
|
||||||
Paths.mkdir(thumbDir);
|
Paths.mkdir(thumbDir);
|
||||||
Proc.runCommand(null, ["test", "-f", thumbPath], function(output, exitCode) {
|
Proc.runCommand(null, ["test", "-f", thumbPath], function (output, exitCode) {
|
||||||
if (exitCode === 0) {
|
if (exitCode === 0) {
|
||||||
_videoThumb = thumbPath;
|
_videoThumb = thumbPath;
|
||||||
} else {
|
} else {
|
||||||
Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", String(size), "-f"], function(output, exitCode) {
|
Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", String(size), "-f"], function (output, exitCode) {
|
||||||
if (exitCode === 0)
|
if (exitCode === 0)
|
||||||
_videoThumb = thumbPath;
|
_videoThumb = thumbPath;
|
||||||
});
|
});
|
||||||
@@ -246,8 +247,16 @@ StyledRect {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: {
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
itemClicked(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
|
onClicked: mouse => {
|
||||||
|
switch (mouse.button) {
|
||||||
|
case Qt.LeftButton:
|
||||||
|
itemClicked(delegateRoot.index, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
|
||||||
|
break;
|
||||||
|
case Qt.RightButton:
|
||||||
|
itemContextMenuRequested(delegateRoot, mouse.x, mouse.y, delegateRoot.filePath, delegateRoot.fileName, delegateRoot.fileIsDir);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
153
quickshell/Modals/FileBrowser/FileBrowserItemContextMenu.qml
Normal file
153
quickshell/Modals/FileBrowser/FileBrowserItemContextMenu.qml
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import Quickshell
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Widgets
|
||||||
|
|
||||||
|
Popup {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string filePath: ""
|
||||||
|
property string fileName: ""
|
||||||
|
property bool fileIsDir: false
|
||||||
|
property var parentFocusItem: null
|
||||||
|
|
||||||
|
signal trashed
|
||||||
|
signal menuClosed
|
||||||
|
|
||||||
|
readonly property var menuItems: [
|
||||||
|
{
|
||||||
|
text: I18n.tr("Move to Trash"),
|
||||||
|
icon: "delete",
|
||||||
|
action: trashItem,
|
||||||
|
enabled: filePath.length > 0,
|
||||||
|
dangerous: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: I18n.tr("Copy Path"),
|
||||||
|
icon: "content_copy",
|
||||||
|
action: copyPath,
|
||||||
|
enabled: filePath.length > 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
function showAt(parentItem, localX, localY, path, name, isDir) {
|
||||||
|
if (!parentItem)
|
||||||
|
return;
|
||||||
|
parent = parentItem;
|
||||||
|
filePath = path || "";
|
||||||
|
fileName = name || "";
|
||||||
|
fileIsDir = !!isDir;
|
||||||
|
x = Math.max(0, Math.min(parentItem.width - width, localX));
|
||||||
|
y = Math.max(0, Math.min(parentItem.height - height, localY));
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function trashItem() {
|
||||||
|
if (!filePath)
|
||||||
|
return;
|
||||||
|
TrashService.trashPath(filePath, ok => {
|
||||||
|
if (ok)
|
||||||
|
root.trashed();
|
||||||
|
});
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyPath() {
|
||||||
|
if (!filePath)
|
||||||
|
return;
|
||||||
|
Quickshell.execDetached(["dms", "cl", "copy", filePath]);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
width: 220
|
||||||
|
height: menuColumn.implicitHeight + Theme.spacingS * 2
|
||||||
|
padding: 0
|
||||||
|
modal: false
|
||||||
|
closePolicy: Popup.CloseOnEscape
|
||||||
|
|
||||||
|
onClosed: {
|
||||||
|
closePolicy = Popup.CloseOnEscape;
|
||||||
|
menuClosed();
|
||||||
|
if (parentFocusItem)
|
||||||
|
Qt.callLater(() => parentFocusItem.forceActiveFocus());
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpened: outsideClickTimer.start()
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: outsideClickTimer
|
||||||
|
interval: 100
|
||||||
|
onTriggered: root.closePolicy = Popup.CloseOnEscape | Popup.CloseOnPressOutside
|
||||||
|
}
|
||||||
|
|
||||||
|
background: Rectangle {
|
||||||
|
color: "transparent"
|
||||||
|
}
|
||||||
|
|
||||||
|
contentItem: Rectangle {
|
||||||
|
color: Theme.floatingSurface
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
||||||
|
border.width: BlurService.enabled ? BlurService.borderWidth : 1
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: menuColumn
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.spacingS
|
||||||
|
spacing: 1
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.menuItems
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 32
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
opacity: modelData.enabled ? 1 : 0.5
|
||||||
|
color: {
|
||||||
|
if (!modelData.enabled || !area.containsMouse)
|
||||||
|
return "transparent";
|
||||||
|
if (modelData.dangerous)
|
||||||
|
return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12);
|
||||||
|
return BlurService.hoverColor(Theme.widgetBaseHoverColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Theme.spacingS
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: Theme.spacingS
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
name: modelData.icon
|
||||||
|
size: 16
|
||||||
|
color: modelData.dangerous && area.containsMouse && modelData.enabled ? Theme.error : Theme.surfaceText
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: modelData.text
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: modelData.dangerous && area.containsMouse && modelData.enabled ? Theme.error : Theme.surfaceText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: area
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
enabled: modelData.enabled
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: modelData.action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ StyledRect {
|
|||||||
|
|
||||||
signal itemClicked(int index, string path, string name, bool isDir)
|
signal itemClicked(int index, string path, string name, bool isDir)
|
||||||
signal itemSelected(int index, string path, string name, bool isDir)
|
signal itemSelected(int index, string path, string name, bool isDir)
|
||||||
|
signal itemContextMenuRequested(var sender, real localX, real localY, string path, string name, bool isDir)
|
||||||
|
|
||||||
function getFileExtension(fileName) {
|
function getFileExtension(fileName) {
|
||||||
const parts = fileName.split('.');
|
const parts = fileName.split('.');
|
||||||
@@ -102,11 +103,11 @@ StyledRect {
|
|||||||
const thumbPath = videoThumbnailPath;
|
const thumbPath = videoThumbnailPath;
|
||||||
const fp = listDelegateRoot.filePath;
|
const fp = listDelegateRoot.filePath;
|
||||||
Paths.mkdir(_xdgCacheHome + "/thumbnails/normal");
|
Paths.mkdir(_xdgCacheHome + "/thumbnails/normal");
|
||||||
Proc.runCommand(null, ["test", "-f", thumbPath], function(output, exitCode) {
|
Proc.runCommand(null, ["test", "-f", thumbPath], function (output, exitCode) {
|
||||||
if (exitCode === 0) {
|
if (exitCode === 0) {
|
||||||
_videoThumb = thumbPath;
|
_videoThumb = thumbPath;
|
||||||
} else {
|
} else {
|
||||||
Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", "128", "-f"], function(output, exitCode) {
|
Proc.runCommand(null, ["ffmpegthumbnailer", "-i", fp, "-o", thumbPath, "-s", "128", "-f"], function (output, exitCode) {
|
||||||
if (exitCode === 0)
|
if (exitCode === 0)
|
||||||
_videoThumb = thumbPath;
|
_videoThumb = thumbPath;
|
||||||
});
|
});
|
||||||
@@ -251,8 +252,16 @@ StyledRect {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: {
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
itemClicked(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
|
onClicked: mouse => {
|
||||||
|
switch (mouse.button) {
|
||||||
|
case Qt.LeftButton:
|
||||||
|
itemClicked(listDelegateRoot.index, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
|
||||||
|
break;
|
||||||
|
case Qt.RightButton:
|
||||||
|
itemContextMenuRequested(listDelegateRoot, mouse.x, mouse.y, listDelegateRoot.filePath, listDelegateRoot.fileName, listDelegateRoot.fileIsDir);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -479,14 +479,33 @@ Item {
|
|||||||
delegate: Item {
|
delegate: Item {
|
||||||
id: delegateItem
|
id: delegateItem
|
||||||
|
|
||||||
property var dockButton: itemData.type === "launcher" ? launcherButton : (itemData.type === "trash" ? trashButton : button)
|
property var dockButton: {
|
||||||
|
switch (itemData.type) {
|
||||||
|
case "launcher":
|
||||||
|
return launcherButton;
|
||||||
|
case "trash":
|
||||||
|
return trashButton;
|
||||||
|
default:
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
}
|
||||||
property var itemData: modelData
|
property var itemData: modelData
|
||||||
readonly property bool isOverflowToggle: itemData.type === "overflow-toggle"
|
readonly property bool isOverflowToggle: itemData.type === "overflow-toggle"
|
||||||
readonly property bool isTrash: itemData.type === "trash"
|
readonly property bool isTrash: itemData.type === "trash"
|
||||||
readonly property bool isInOverflow: itemData.isInOverflow === true
|
readonly property bool isInOverflow: itemData.isInOverflow === true
|
||||||
|
readonly property bool isDragging: {
|
||||||
|
switch (itemData.type) {
|
||||||
|
case "launcher":
|
||||||
|
return launcherButton.dragging;
|
||||||
|
case "trash":
|
||||||
|
return false;
|
||||||
|
default:
|
||||||
|
return button.dragging;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clip: false
|
clip: false
|
||||||
z: (itemData.type === "launcher" ? launcherButton.dragging : (itemData.type === "trash" ? false : button.dragging)) ? 100 : 0
|
z: isDragging ? 100 : 0
|
||||||
visible: !isInOverflow || root.overflowExpanded
|
visible: !isInOverflow || root.overflowExpanded
|
||||||
opacity: (isInOverflow && !root.overflowExpanded) ? 0 : 1
|
opacity: (isInOverflow && !root.overflowExpanded) ? 0 : 1
|
||||||
scale: (isInOverflow && !root.overflowExpanded) ? 0.8 : 1
|
scale: (isInOverflow && !root.overflowExpanded) ? 0.8 : 1
|
||||||
|
|||||||
@@ -1,585 +1,390 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
|
||||||
import Quickshell.Wayland
|
|
||||||
import Quickshell.Widgets
|
import Quickshell.Widgets
|
||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
|
|
||||||
PanelWindow {
|
DockContextMenuBase {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
WindowBlur {
|
|
||||||
targetWindow: root
|
|
||||||
blurX: menuContainer.x
|
|
||||||
blurY: menuContainer.y
|
|
||||||
blurWidth: root.visible ? menuContainer.width : 0
|
|
||||||
blurHeight: root.visible ? menuContainer.height : 0
|
|
||||||
blurRadius: Theme.cornerRadius
|
|
||||||
}
|
|
||||||
|
|
||||||
WlrLayershell.namespace: "dms:dock-context-menu"
|
|
||||||
|
|
||||||
property var appData: null
|
property var appData: null
|
||||||
property var anchorItem: null
|
|
||||||
property real dockVisibleHeight: 40
|
|
||||||
property int margin: 10
|
|
||||||
property bool hidePin: false
|
property bool hidePin: false
|
||||||
property var desktopEntry: null
|
property var desktopEntry: null
|
||||||
property bool isDmsWindow: appData?.appId === "org.quickshell" || appData?.appId === "com.danklinux.dms"
|
|
||||||
property var dockApps: null
|
property var dockApps: null
|
||||||
|
readonly property bool isDmsWindow: appData?.appId === "org.quickshell" || appData?.appId === "com.danklinux.dms"
|
||||||
|
|
||||||
|
layerNamespace: "dms:dock-context-menu"
|
||||||
|
|
||||||
function showForButton(button, data, dockHeight, hidePinOption, entry, dockScreen, parentDockApps) {
|
function showForButton(button, data, dockHeight, hidePinOption, entry, dockScreen, parentDockApps) {
|
||||||
if (dockScreen) {
|
|
||||||
root.screen = dockScreen;
|
|
||||||
}
|
|
||||||
|
|
||||||
anchorItem = button;
|
|
||||||
appData = data;
|
appData = data;
|
||||||
dockVisibleHeight = dockHeight || 40;
|
|
||||||
hidePin = hidePinOption || false;
|
hidePin = hidePinOption || false;
|
||||||
desktopEntry = entry || null;
|
desktopEntry = entry || null;
|
||||||
dockApps = parentDockApps || null;
|
dockApps = parentDockApps || null;
|
||||||
|
show(button, dockHeight, dockScreen);
|
||||||
visible = true;
|
|
||||||
}
|
|
||||||
function close() {
|
|
||||||
visible = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
screen: null
|
Repeater {
|
||||||
visible: false
|
model: {
|
||||||
WlrLayershell.layer: WlrLayershell.Overlay
|
if (!root.appData || root.appData.type !== "grouped")
|
||||||
WlrLayershell.exclusiveZone: -1
|
return [];
|
||||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
|
||||||
color: "transparent"
|
|
||||||
anchors {
|
|
||||||
top: true
|
|
||||||
left: true
|
|
||||||
right: true
|
|
||||||
bottom: true
|
|
||||||
}
|
|
||||||
|
|
||||||
property point anchorPos: Qt.point(screen.width / 2, screen.height - 100)
|
const toplevels = [];
|
||||||
|
const allToplevels = ToplevelManager.toplevels.values;
|
||||||
onAnchorItemChanged: updatePosition()
|
for (let i = 0; i < allToplevels.length; i++) {
|
||||||
onVisibleChanged: {
|
const toplevel = allToplevels[i];
|
||||||
if (visible) {
|
if (toplevel.appId === root.appData.appId) {
|
||||||
updatePosition();
|
toplevels.push(toplevel);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePosition() {
|
|
||||||
if (!anchorItem) {
|
|
||||||
anchorPos = Qt.point(screen.width / 2, screen.height - 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dockWindow = anchorItem.Window.window;
|
|
||||||
if (!dockWindow) {
|
|
||||||
anchorPos = Qt.point(screen.width / 2, screen.height - 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttonPosInDock = anchorItem.mapToItem(dockWindow.contentItem, 0, 0);
|
|
||||||
let actualDockHeight = root.dockVisibleHeight;
|
|
||||||
|
|
||||||
function findDockBackground(item) {
|
|
||||||
if (item.objectName === "dockBackground") {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
for (var i = 0; i < item.children.length; i++) {
|
|
||||||
const found = findDockBackground(item.children[i]);
|
|
||||||
if (found) {
|
|
||||||
return found;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return toplevels;
|
||||||
}
|
|
||||||
|
|
||||||
const dockBackground = findDockBackground(dockWindow.contentItem);
|
|
||||||
let actualDockWidth = dockWindow.width;
|
|
||||||
if (dockBackground) {
|
|
||||||
actualDockHeight = dockBackground.height;
|
|
||||||
actualDockWidth = dockBackground.width;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isVertical = SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right;
|
|
||||||
const dockMargin = SettingsData.dockMargin + 16;
|
|
||||||
let buttonScreenX, buttonScreenY;
|
|
||||||
|
|
||||||
if (isVertical) {
|
|
||||||
const dockContentHeight = dockWindow.height;
|
|
||||||
const screenHeight = root.screen.height;
|
|
||||||
const dockTopMargin = Math.round((screenHeight - dockContentHeight) / 2);
|
|
||||||
buttonScreenY = dockTopMargin + buttonPosInDock.y + anchorItem.height / 2;
|
|
||||||
|
|
||||||
if (SettingsData.dockPosition === SettingsData.Position.Right) {
|
|
||||||
buttonScreenX = root.screen.width - actualDockWidth - dockMargin - 20;
|
|
||||||
} else {
|
|
||||||
buttonScreenX = actualDockWidth + dockMargin + 20;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const isDockAtBottom = SettingsData.dockPosition === SettingsData.Position.Bottom;
|
|
||||||
|
|
||||||
if (isDockAtBottom) {
|
|
||||||
buttonScreenY = root.screen.height - actualDockHeight - dockMargin - 20;
|
|
||||||
} else {
|
|
||||||
buttonScreenY = actualDockHeight + dockMargin + 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dockContentWidth = dockWindow.width;
|
|
||||||
const screenWidth = root.screen.width;
|
|
||||||
const dockLeftMargin = Math.round((screenWidth - dockContentWidth) / 2);
|
|
||||||
buttonScreenX = dockLeftMargin + buttonPosInDock.x + anchorItem.width / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
anchorPos = Qt.point(buttonScreenX, buttonScreenY);
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
id: menuContainer
|
|
||||||
|
|
||||||
x: {
|
|
||||||
const isVertical = SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right;
|
|
||||||
if (isVertical) {
|
|
||||||
const isDockAtRight = SettingsData.dockPosition === SettingsData.Position.Right;
|
|
||||||
if (isDockAtRight) {
|
|
||||||
return Math.max(10, root.anchorPos.x - width + 30);
|
|
||||||
} else {
|
|
||||||
return Math.min(root.width - width - 10, root.anchorPos.x - 30);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const left = 10;
|
|
||||||
const right = root.width - width - 10;
|
|
||||||
const want = root.anchorPos.x - width / 2;
|
|
||||||
return Math.max(left, Math.min(right, want));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
y: {
|
|
||||||
const isVertical = SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right;
|
|
||||||
if (isVertical) {
|
|
||||||
const top = 10;
|
|
||||||
const bottom = root.height - height - 10;
|
|
||||||
const want = root.anchorPos.y - height / 2;
|
|
||||||
return Math.max(top, Math.min(bottom, want));
|
|
||||||
} else {
|
|
||||||
const isDockAtBottom = SettingsData.dockPosition === SettingsData.Position.Bottom;
|
|
||||||
if (isDockAtBottom) {
|
|
||||||
return Math.max(10, root.anchorPos.y - height + 30);
|
|
||||||
} else {
|
|
||||||
return Math.min(root.height - height - 10, root.anchorPos.y - 30);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
width: Math.min(400, Math.max(180, menuColumn.implicitWidth + Theme.spacingS * 2))
|
|
||||||
height: menuColumn.implicitHeight + Theme.spacingS * 2
|
|
||||||
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
|
||||||
border.width: BlurService.enabled ? BlurService.borderWidth : 1
|
|
||||||
|
|
||||||
opacity: root.visible ? 1 : 0
|
|
||||||
visible: opacity > 0
|
|
||||||
|
|
||||||
Behavior on opacity {
|
|
||||||
NumberAnimation {
|
|
||||||
duration: Theme.shortDuration
|
|
||||||
easing.type: Theme.emphasizedEasing
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
anchors.fill: parent
|
width: parent.width
|
||||||
anchors.topMargin: 4
|
height: 28
|
||||||
anchors.leftMargin: 2
|
radius: Theme.cornerRadius
|
||||||
anchors.rightMargin: -2
|
color: windowArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
||||||
anchors.bottomMargin: -4
|
|
||||||
radius: parent.radius
|
|
||||||
color: Qt.rgba(0, 0, 0, 0.15)
|
|
||||||
z: -1
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
StyledText {
|
||||||
id: menuColumn
|
anchors.left: parent.left
|
||||||
width: parent.width - Theme.spacingS * 2
|
anchors.leftMargin: Theme.spacingS
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.right: closeButton.left
|
||||||
anchors.top: parent.top
|
anchors.rightMargin: Theme.spacingXS
|
||||||
anchors.topMargin: Theme.spacingS
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 1
|
text: (modelData && modelData.title) ? modelData.title : I18n.tr("(Unnamed)")
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
// Window list for grouped apps
|
color: Theme.surfaceText
|
||||||
Repeater {
|
font.weight: Font.Normal
|
||||||
model: {
|
elide: Text.ElideRight
|
||||||
if (!root.appData || root.appData.type !== "grouped")
|
wrapMode: Text.NoWrap
|
||||||
return [];
|
|
||||||
|
|
||||||
const toplevels = [];
|
|
||||||
const allToplevels = ToplevelManager.toplevels.values;
|
|
||||||
for (let i = 0; i < allToplevels.length; i++) {
|
|
||||||
const toplevel = allToplevels[i];
|
|
||||||
if (toplevel.appId === root.appData.appId) {
|
|
||||||
toplevels.push(toplevel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return toplevels;
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
width: parent.width
|
|
||||||
height: 28
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: windowArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.leftMargin: Theme.spacingS
|
|
||||||
anchors.right: closeButton.left
|
|
||||||
anchors.rightMargin: Theme.spacingXS
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: (modelData && modelData.title) ? modelData.title : I18n.tr("(Unnamed)")
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceText
|
|
||||||
font.weight: Font.Normal
|
|
||||||
elide: Text.ElideRight
|
|
||||||
wrapMode: Text.NoWrap
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
id: closeButton
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.rightMargin: Theme.spacingXS
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
width: 20
|
|
||||||
height: 20
|
|
||||||
radius: 10
|
|
||||||
color: closeMouseArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.2) : "transparent"
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
anchors.centerIn: parent
|
|
||||||
name: "close"
|
|
||||||
size: 12
|
|
||||||
color: closeMouseArea.containsMouse ? Theme.error : Theme.surfaceText
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: closeMouseArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onClicked: {
|
|
||||||
if (modelData && modelData.close) {
|
|
||||||
modelData.close();
|
|
||||||
}
|
|
||||||
root.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DankRipple {
|
|
||||||
id: windowRipple
|
|
||||||
rippleColor: Theme.surfaceText
|
|
||||||
cornerRadius: Theme.cornerRadius
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: windowArea
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.rightMargin: 24
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onPressed: mouse => windowRipple.trigger(mouse.x, mouse.y)
|
|
||||||
onClicked: {
|
|
||||||
if (modelData && modelData.activate) {
|
|
||||||
modelData.activate();
|
|
||||||
}
|
|
||||||
root.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
visible: {
|
id: closeButton
|
||||||
if (!root.appData)
|
anchors.right: parent.right
|
||||||
return false;
|
anchors.rightMargin: Theme.spacingXS
|
||||||
if (root.appData.type !== "grouped")
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
return false;
|
width: 20
|
||||||
return root.appData.windowCount > 0;
|
height: 20
|
||||||
}
|
radius: 10
|
||||||
width: parent.width
|
color: closeMouseArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.2) : "transparent"
|
||||||
height: 1
|
|
||||||
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
|
|
||||||
}
|
|
||||||
|
|
||||||
Repeater {
|
DankIcon {
|
||||||
model: root.desktopEntry && root.desktopEntry.actions ? root.desktopEntry.actions : []
|
anchors.centerIn: parent
|
||||||
|
name: "close"
|
||||||
Rectangle {
|
size: 12
|
||||||
width: parent.width
|
color: closeMouseArea.containsMouse ? Theme.error : Theme.surfaceText
|
||||||
height: 28
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: actionArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.leftMargin: Theme.spacingS
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.rightMargin: Theme.spacingS
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: Theme.spacingXS
|
|
||||||
|
|
||||||
Item {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
width: 16
|
|
||||||
height: 16
|
|
||||||
visible: modelData.icon && modelData.icon !== ""
|
|
||||||
|
|
||||||
IconImage {
|
|
||||||
anchors.fill: parent
|
|
||||||
source: modelData.icon ? Paths.resolveIconPath(modelData.icon) : ""
|
|
||||||
smooth: true
|
|
||||||
asynchronous: true
|
|
||||||
visible: status === Image.Ready
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: modelData.name || ""
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceText
|
|
||||||
font.weight: Font.Normal
|
|
||||||
elide: Text.ElideRight
|
|
||||||
wrapMode: Text.NoWrap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DankRipple {
|
|
||||||
id: actionRipple
|
|
||||||
rippleColor: Theme.surfaceText
|
|
||||||
cornerRadius: Theme.cornerRadius
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: actionArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onPressed: mouse => actionRipple.trigger(mouse.x, mouse.y)
|
|
||||||
onClicked: {
|
|
||||||
if (modelData) {
|
|
||||||
SessionService.launchDesktopAction(root.desktopEntry, modelData);
|
|
||||||
}
|
|
||||||
root.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
visible: {
|
|
||||||
if (!root.desktopEntry?.actions || root.desktopEntry.actions.length === 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return !root.hidePin || (!root.isDmsWindow && root.desktopEntry && SessionService.nvidiaCommand);
|
|
||||||
}
|
|
||||||
width: parent.width
|
|
||||||
height: 1
|
|
||||||
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
visible: !root.hidePin
|
|
||||||
width: parent.width
|
|
||||||
height: 28
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: pinArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.leftMargin: Theme.spacingS
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.rightMargin: Theme.spacingS
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: Theme.spacingXS
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
name: root.appData && root.appData.isPinned ? "keep_off" : "push_pin"
|
|
||||||
size: 14
|
|
||||||
color: Theme.surfaceText
|
|
||||||
opacity: 0.7
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: root.appData && root.appData.isPinned ? I18n.tr("Unpin from Dock") : I18n.tr("Pin to Dock")
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceText
|
|
||||||
font.weight: Font.Normal
|
|
||||||
elide: Text.ElideRight
|
|
||||||
wrapMode: Text.NoWrap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DankRipple {
|
|
||||||
id: pinRipple
|
|
||||||
rippleColor: Theme.surfaceText
|
|
||||||
cornerRadius: Theme.cornerRadius
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
id: pinArea
|
id: closeMouseArea
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y)
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
if (!root.appData)
|
if (modelData && modelData.close) {
|
||||||
return;
|
modelData.close();
|
||||||
|
|
||||||
if (root.appData.isPinned) {
|
|
||||||
SessionData.removePinnedApp(root.appData.appId);
|
|
||||||
} else {
|
|
||||||
SessionData.addPinnedApp(root.appData.appId);
|
|
||||||
}
|
}
|
||||||
root.close();
|
root.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
DankRipple {
|
||||||
visible: {
|
id: windowRipple
|
||||||
const hasNvidia = !root.isDmsWindow && root.desktopEntry && SessionService.nvidiaCommand;
|
rippleColor: Theme.surfaceText
|
||||||
const hasWindow = root.appData && (root.appData.type === "window" || (root.appData.type === "grouped" && root.appData.windowCount > 0));
|
cornerRadius: Theme.cornerRadius
|
||||||
const hasPinOption = !root.hidePin;
|
|
||||||
const hasContentAbove = hasPinOption || hasNvidia;
|
|
||||||
return hasContentAbove && hasWindow;
|
|
||||||
}
|
|
||||||
width: parent.width
|
|
||||||
height: 1
|
|
||||||
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
MouseArea {
|
||||||
visible: !root.isDmsWindow && root.desktopEntry && SessionService.nvidiaCommand
|
id: windowArea
|
||||||
width: parent.width
|
anchors.fill: parent
|
||||||
height: 28
|
anchors.rightMargin: 24
|
||||||
radius: Theme.cornerRadius
|
hoverEnabled: true
|
||||||
color: nvidiaArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onPressed: mouse => windowRipple.trigger(mouse.x, mouse.y)
|
||||||
Row {
|
onClicked: {
|
||||||
anchors.left: parent.left
|
if (modelData && modelData.activate) {
|
||||||
anchors.leftMargin: Theme.spacingS
|
modelData.activate();
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.rightMargin: Theme.spacingS
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: Theme.spacingXS
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
name: "memory"
|
|
||||||
size: 14
|
|
||||||
color: Theme.surfaceText
|
|
||||||
opacity: 0.7
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: I18n.tr("Launch on dGPU")
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceText
|
|
||||||
font.weight: Font.Normal
|
|
||||||
elide: Text.ElideRight
|
|
||||||
wrapMode: Text.NoWrap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DankRipple {
|
|
||||||
id: nvidiaRipple
|
|
||||||
rippleColor: Theme.surfaceText
|
|
||||||
cornerRadius: Theme.cornerRadius
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: nvidiaArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onPressed: mouse => nvidiaRipple.trigger(mouse.x, mouse.y)
|
|
||||||
onClicked: {
|
|
||||||
if (root.desktopEntry) {
|
|
||||||
SessionService.launchDesktopEntry(root.desktopEntry, true);
|
|
||||||
}
|
|
||||||
root.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
visible: root.appData && (root.appData.type === "window" || (root.appData.type === "grouped" && root.appData.windowCount > 0))
|
|
||||||
width: parent.width
|
|
||||||
height: 28
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: closeArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent"
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.leftMargin: Theme.spacingS
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.rightMargin: Theme.spacingS
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: Theme.spacingXS
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
name: "close"
|
|
||||||
size: 14
|
|
||||||
color: closeArea.containsMouse ? Theme.error : Theme.surfaceText
|
|
||||||
opacity: 0.7
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: root.appData && root.appData.type === "grouped" ? I18n.tr("Close All Windows") : I18n.tr("Close Window")
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: closeArea.containsMouse ? Theme.error : Theme.surfaceText
|
|
||||||
font.weight: Font.Normal
|
|
||||||
elide: Text.ElideRight
|
|
||||||
wrapMode: Text.NoWrap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DankRipple {
|
|
||||||
id: closeRipple
|
|
||||||
rippleColor: Theme.error
|
|
||||||
cornerRadius: Theme.cornerRadius
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: closeArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onPressed: mouse => closeRipple.trigger(mouse.x, mouse.y)
|
|
||||||
onClicked: {
|
|
||||||
if (root.appData?.type === "window") {
|
|
||||||
root.appData?.toplevel?.close();
|
|
||||||
} else if (root.appData?.type === "grouped") {
|
|
||||||
root.appData?.allWindows?.forEach(window => window.toplevel?.close());
|
|
||||||
}
|
|
||||||
root.close();
|
|
||||||
}
|
}
|
||||||
|
root.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
Rectangle {
|
||||||
anchors.fill: parent
|
visible: {
|
||||||
z: -1
|
if (!root.appData)
|
||||||
onClicked: root.close()
|
return false;
|
||||||
|
if (root.appData.type !== "grouped")
|
||||||
|
return false;
|
||||||
|
return root.appData.windowCount > 0;
|
||||||
|
}
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.desktopEntry && root.desktopEntry.actions ? root.desktopEntry.actions : []
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 28
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: actionArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Theme.spacingS
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: Theme.spacingS
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
|
||||||
|
Item {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 16
|
||||||
|
height: 16
|
||||||
|
visible: modelData.icon && modelData.icon !== ""
|
||||||
|
|
||||||
|
IconImage {
|
||||||
|
anchors.fill: parent
|
||||||
|
source: modelData.icon ? Paths.resolveIconPath(modelData.icon) : ""
|
||||||
|
smooth: true
|
||||||
|
asynchronous: true
|
||||||
|
visible: status === Image.Ready
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: modelData.name || ""
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.weight: Font.Normal
|
||||||
|
elide: Text.ElideRight
|
||||||
|
wrapMode: Text.NoWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankRipple {
|
||||||
|
id: actionRipple
|
||||||
|
rippleColor: Theme.surfaceText
|
||||||
|
cornerRadius: Theme.cornerRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: actionArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onPressed: mouse => actionRipple.trigger(mouse.x, mouse.y)
|
||||||
|
onClicked: {
|
||||||
|
if (modelData) {
|
||||||
|
SessionService.launchDesktopAction(root.desktopEntry, modelData);
|
||||||
|
}
|
||||||
|
root.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: {
|
||||||
|
if (!root.desktopEntry?.actions || root.desktopEntry.actions.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !root.hidePin || (!root.isDmsWindow && root.desktopEntry && SessionService.nvidiaCommand);
|
||||||
|
}
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: !root.hidePin
|
||||||
|
width: parent.width
|
||||||
|
height: 28
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: pinArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Theme.spacingS
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: Theme.spacingS
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
name: root.appData && root.appData.isPinned ? "keep_off" : "push_pin"
|
||||||
|
size: 14
|
||||||
|
color: Theme.surfaceText
|
||||||
|
opacity: 0.7
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.appData && root.appData.isPinned ? I18n.tr("Unpin from Dock") : I18n.tr("Pin to Dock")
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.weight: Font.Normal
|
||||||
|
elide: Text.ElideRight
|
||||||
|
wrapMode: Text.NoWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankRipple {
|
||||||
|
id: pinRipple
|
||||||
|
rippleColor: Theme.surfaceText
|
||||||
|
cornerRadius: Theme.cornerRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: pinArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onPressed: mouse => pinRipple.trigger(mouse.x, mouse.y)
|
||||||
|
onClicked: {
|
||||||
|
if (!root.appData)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (root.appData.isPinned) {
|
||||||
|
SessionData.removePinnedApp(root.appData.appId);
|
||||||
|
} else {
|
||||||
|
SessionData.addPinnedApp(root.appData.appId);
|
||||||
|
}
|
||||||
|
root.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: {
|
||||||
|
const hasNvidia = !root.isDmsWindow && root.desktopEntry && SessionService.nvidiaCommand;
|
||||||
|
const hasWindow = root.appData && (root.appData.type === "window" || (root.appData.type === "grouped" && root.appData.windowCount > 0));
|
||||||
|
const hasPinOption = !root.hidePin;
|
||||||
|
const hasContentAbove = hasPinOption || hasNvidia;
|
||||||
|
return hasContentAbove && hasWindow;
|
||||||
|
}
|
||||||
|
width: parent.width
|
||||||
|
height: 1
|
||||||
|
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: !root.isDmsWindow && root.desktopEntry && SessionService.nvidiaCommand
|
||||||
|
width: parent.width
|
||||||
|
height: 28
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: nvidiaArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Theme.spacingS
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: Theme.spacingS
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
name: "memory"
|
||||||
|
size: 14
|
||||||
|
color: Theme.surfaceText
|
||||||
|
opacity: 0.7
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: I18n.tr("Launch on dGPU")
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceText
|
||||||
|
font.weight: Font.Normal
|
||||||
|
elide: Text.ElideRight
|
||||||
|
wrapMode: Text.NoWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankRipple {
|
||||||
|
id: nvidiaRipple
|
||||||
|
rippleColor: Theme.surfaceText
|
||||||
|
cornerRadius: Theme.cornerRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: nvidiaArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onPressed: mouse => nvidiaRipple.trigger(mouse.x, mouse.y)
|
||||||
|
onClicked: {
|
||||||
|
if (root.desktopEntry) {
|
||||||
|
SessionService.launchDesktopEntry(root.desktopEntry, true);
|
||||||
|
}
|
||||||
|
root.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
visible: root.appData && (root.appData.type === "window" || (root.appData.type === "grouped" && root.appData.windowCount > 0))
|
||||||
|
width: parent.width
|
||||||
|
height: 28
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: closeArea.containsMouse ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent"
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Theme.spacingS
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: Theme.spacingS
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
name: "close"
|
||||||
|
size: 14
|
||||||
|
color: closeArea.containsMouse ? Theme.error : Theme.surfaceText
|
||||||
|
opacity: 0.7
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.appData && root.appData.type === "grouped" ? I18n.tr("Close All Windows") : I18n.tr("Close Window")
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: closeArea.containsMouse ? Theme.error : Theme.surfaceText
|
||||||
|
font.weight: Font.Normal
|
||||||
|
elide: Text.ElideRight
|
||||||
|
wrapMode: Text.NoWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankRipple {
|
||||||
|
id: closeRipple
|
||||||
|
rippleColor: Theme.error
|
||||||
|
cornerRadius: Theme.cornerRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: closeArea
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onPressed: mouse => closeRipple.trigger(mouse.x, mouse.y)
|
||||||
|
onClicked: {
|
||||||
|
if (root.appData?.type === "window") {
|
||||||
|
root.appData?.toplevel?.close();
|
||||||
|
} else if (root.appData?.type === "grouped") {
|
||||||
|
root.appData?.allWindows?.forEach(window => window.toplevel?.close());
|
||||||
|
}
|
||||||
|
root.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
199
quickshell/Modules/Dock/DockContextMenuBase.qml
Normal file
199
quickshell/Modules/Dock/DockContextMenuBase.qml
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Widgets
|
||||||
|
|
||||||
|
PanelWindow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
default property alias content: menuColumn.children
|
||||||
|
|
||||||
|
property var anchorItem: null
|
||||||
|
property real dockVisibleHeight: 40
|
||||||
|
property int margin: 10
|
||||||
|
property string layerNamespace: "dms:dock-context-menu"
|
||||||
|
property real menuMaxWidth: 400
|
||||||
|
property real menuMinWidth: 180
|
||||||
|
|
||||||
|
property point anchorPos: Qt.point(screen ? screen.width / 2 : 0, screen ? screen.height - 100 : 0)
|
||||||
|
|
||||||
|
function show(button, dockHeight, dockScreen) {
|
||||||
|
if (dockScreen)
|
||||||
|
screen = dockScreen;
|
||||||
|
anchorItem = button;
|
||||||
|
dockVisibleHeight = dockHeight || 40;
|
||||||
|
visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDockBackground(item) {
|
||||||
|
if (!item)
|
||||||
|
return null;
|
||||||
|
if (item.objectName === "dockBackground")
|
||||||
|
return item;
|
||||||
|
for (let i = 0; i < item.children.length; i++) {
|
||||||
|
const found = findDockBackground(item.children[i]);
|
||||||
|
if (found)
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePosition() {
|
||||||
|
if (!anchorItem || !screen) {
|
||||||
|
anchorPos = Qt.point(screen ? screen.width / 2 : 0, screen ? screen.height - 100 : 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dockWindow = anchorItem.Window.window;
|
||||||
|
if (!dockWindow) {
|
||||||
|
anchorPos = Qt.point(screen.width / 2, screen.height - 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttonPosInDock = anchorItem.mapToItem(dockWindow.contentItem, 0, 0);
|
||||||
|
const dockBackground = findDockBackground(dockWindow.contentItem);
|
||||||
|
const actualDockHeight = dockBackground ? dockBackground.height : root.dockVisibleHeight;
|
||||||
|
const actualDockWidth = dockBackground ? dockBackground.width : dockWindow.width;
|
||||||
|
const dockMargin = SettingsData.dockMargin + 16;
|
||||||
|
let x = 0;
|
||||||
|
let y = 0;
|
||||||
|
|
||||||
|
switch (SettingsData.dockPosition) {
|
||||||
|
case SettingsData.Position.Left:
|
||||||
|
{
|
||||||
|
const dockTopMargin = Math.round((screen.height - dockWindow.height) / 2);
|
||||||
|
x = actualDockWidth + dockMargin + 20;
|
||||||
|
y = dockTopMargin + buttonPosInDock.y + anchorItem.height / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case SettingsData.Position.Right:
|
||||||
|
{
|
||||||
|
const dockTopMargin = Math.round((screen.height - dockWindow.height) / 2);
|
||||||
|
x = screen.width - actualDockWidth - dockMargin - 20;
|
||||||
|
y = dockTopMargin + buttonPosInDock.y + anchorItem.height / 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case SettingsData.Position.Top:
|
||||||
|
{
|
||||||
|
const dockLeftMargin = Math.round((screen.width - dockWindow.width) / 2);
|
||||||
|
x = dockLeftMargin + buttonPosInDock.x + anchorItem.width / 2;
|
||||||
|
y = actualDockHeight + dockMargin + 20;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case SettingsData.Position.Bottom:
|
||||||
|
default:
|
||||||
|
{
|
||||||
|
const dockLeftMargin = Math.round((screen.width - dockWindow.width) / 2);
|
||||||
|
x = dockLeftMargin + buttonPosInDock.x + anchorItem.width / 2;
|
||||||
|
y = screen.height - actualDockHeight - dockMargin - 20;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
anchorPos = Qt.point(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
onAnchorItemChanged: updatePosition()
|
||||||
|
onVisibleChanged: {
|
||||||
|
if (visible)
|
||||||
|
updatePosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowBlur {
|
||||||
|
targetWindow: root
|
||||||
|
blurX: menuContainer.x
|
||||||
|
blurY: menuContainer.y
|
||||||
|
blurWidth: root.visible ? menuContainer.width : 0
|
||||||
|
blurHeight: root.visible ? menuContainer.height : 0
|
||||||
|
blurRadius: Theme.cornerRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
WlrLayershell.namespace: root.layerNamespace
|
||||||
|
WlrLayershell.layer: WlrLayershell.Overlay
|
||||||
|
WlrLayershell.exclusiveZone: -1
|
||||||
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||||
|
|
||||||
|
screen: null
|
||||||
|
visible: false
|
||||||
|
color: "transparent"
|
||||||
|
anchors {
|
||||||
|
top: true
|
||||||
|
left: true
|
||||||
|
right: true
|
||||||
|
bottom: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: menuContainer
|
||||||
|
|
||||||
|
readonly property bool isVertical: SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right
|
||||||
|
|
||||||
|
x: {
|
||||||
|
if (!isVertical) {
|
||||||
|
const want = root.anchorPos.x - width / 2;
|
||||||
|
return Math.max(10, Math.min(root.width - width - 10, want));
|
||||||
|
}
|
||||||
|
if (SettingsData.dockPosition === SettingsData.Position.Right)
|
||||||
|
return Math.max(10, root.anchorPos.x - width + 30);
|
||||||
|
return Math.min(root.width - width - 10, root.anchorPos.x - 30);
|
||||||
|
}
|
||||||
|
y: {
|
||||||
|
if (isVertical) {
|
||||||
|
const want = root.anchorPos.y - height / 2;
|
||||||
|
return Math.max(10, Math.min(root.height - height - 10, want));
|
||||||
|
}
|
||||||
|
if (SettingsData.dockPosition === SettingsData.Position.Bottom)
|
||||||
|
return Math.max(10, root.anchorPos.y - height + 30);
|
||||||
|
return Math.min(root.height - height - 10, root.anchorPos.y - 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
width: Math.min(root.menuMaxWidth, Math.max(root.menuMinWidth, menuColumn.implicitWidth + Theme.spacingS * 2))
|
||||||
|
height: menuColumn.implicitHeight + Theme.spacingS * 2
|
||||||
|
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
||||||
|
border.width: BlurService.enabled ? BlurService.borderWidth : 1
|
||||||
|
|
||||||
|
opacity: root.visible ? 1 : 0
|
||||||
|
visible: opacity > 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation {
|
||||||
|
duration: Theme.shortDuration
|
||||||
|
easing.type: Theme.emphasizedEasing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.topMargin: 4
|
||||||
|
anchors.leftMargin: 2
|
||||||
|
anchors.rightMargin: -2
|
||||||
|
anchors.bottomMargin: -4
|
||||||
|
radius: parent.radius
|
||||||
|
color: Qt.rgba(0, 0, 0, 0.15)
|
||||||
|
z: -1
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: menuColumn
|
||||||
|
width: parent.width - Theme.spacingS * 2
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: Theme.spacingS
|
||||||
|
spacing: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
z: -1
|
||||||
|
onClicked: root.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
|
||||||
import Quickshell.Widgets
|
import Quickshell.Widgets
|
||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Services
|
import qs.Services
|
||||||
@@ -16,40 +15,39 @@ Item {
|
|||||||
property real actualIconSize: 40
|
property real actualIconSize: 40
|
||||||
property real hoverAnimOffset: 0
|
property real hoverAnimOffset: 0
|
||||||
|
|
||||||
property bool isHovered: mouseArea.containsMouse
|
readonly property bool isHovered: mouseArea.containsMouse
|
||||||
property bool showTooltip: mouseArea.containsMouse
|
readonly property bool showTooltip: mouseArea.containsMouse
|
||||||
readonly property string tooltipText: TrashService.isEmpty ? I18n.tr("Trash") : (I18n.tr("Trash") + " (" + TrashService.count + ")")
|
readonly property string tooltipText: TrashService.isEmpty ? I18n.tr("Trash") : (I18n.tr("Trash") + " (" + TrashService.count + ")")
|
||||||
|
|
||||||
readonly property bool isVertical: SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right
|
readonly property bool isVertical: SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right
|
||||||
readonly property real animationDistance: actualIconSize
|
readonly property real animationDistance: actualIconSize
|
||||||
readonly property real animationDirection: {
|
readonly property real animationDirection: {
|
||||||
if (SettingsData.dockPosition === SettingsData.Position.Bottom)
|
switch (SettingsData.dockPosition) {
|
||||||
return -1;
|
case SettingsData.Position.Top:
|
||||||
if (SettingsData.dockPosition === SettingsData.Position.Top)
|
case SettingsData.Position.Left:
|
||||||
return 1;
|
return 1;
|
||||||
if (SettingsData.dockPosition === SettingsData.Position.Right)
|
case SettingsData.Position.Bottom:
|
||||||
|
case SettingsData.Position.Right:
|
||||||
|
default:
|
||||||
return -1;
|
return -1;
|
||||||
if (SettingsData.dockPosition === SettingsData.Position.Left)
|
}
|
||||||
return 1;
|
|
||||||
return -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onIsHoveredChanged: {
|
onIsHoveredChanged: {
|
||||||
if (mouseArea.pressed)
|
if (mouseArea.pressed)
|
||||||
return;
|
return;
|
||||||
if (isHovered) {
|
if (!isHovered) {
|
||||||
exitAnimation.stop();
|
|
||||||
if (!bounceAnimation.running)
|
|
||||||
bounceAnimation.restart();
|
|
||||||
} else {
|
|
||||||
bounceAnimation.stop();
|
bounceAnimation.stop();
|
||||||
exitAnimation.restart();
|
exitAnimation.restart();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
exitAnimation.stop();
|
||||||
|
if (!bounceAnimation.running)
|
||||||
|
bounceAnimation.restart();
|
||||||
}
|
}
|
||||||
|
|
||||||
SequentialAnimation {
|
SequentialAnimation {
|
||||||
id: bounceAnimation
|
id: bounceAnimation
|
||||||
|
|
||||||
running: false
|
running: false
|
||||||
|
|
||||||
NumberAnimation {
|
NumberAnimation {
|
||||||
@@ -73,7 +71,6 @@ Item {
|
|||||||
|
|
||||||
NumberAnimation {
|
NumberAnimation {
|
||||||
id: exitAnimation
|
id: exitAnimation
|
||||||
|
|
||||||
running: false
|
running: false
|
||||||
target: root
|
target: root
|
||||||
property: "hoverAnimOffset"
|
property: "hoverAnimOffset"
|
||||||
@@ -85,52 +82,56 @@ Item {
|
|||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
id: mouseArea
|
id: mouseArea
|
||||||
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||||
|
|
||||||
onClicked: mouse => {
|
onClicked: mouse => {
|
||||||
if (mouse.button === Qt.LeftButton) {
|
switch (mouse.button) {
|
||||||
|
case Qt.LeftButton:
|
||||||
TrashService.openTrash();
|
TrashService.openTrash();
|
||||||
} else if (mouse.button === Qt.RightButton) {
|
break;
|
||||||
if (contextMenu) {
|
case Qt.RightButton:
|
||||||
|
if (contextMenu)
|
||||||
contextMenu.showForButton(root, root.height, parentDockScreen, dockApps);
|
contextMenu.showForButton(root, root.height, parentDockScreen, dockApps);
|
||||||
}
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: visualContent
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
|
|
||||||
transform: Translate {
|
transform: Translate {
|
||||||
x: !isVertical ? 0 : hoverAnimOffset
|
x: isVertical ? hoverAnimOffset : 0
|
||||||
y: !isVertical ? hoverAnimOffset : 0
|
y: isVertical ? 0 : hoverAnimOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
width: actualIconSize
|
width: actualIconSize - 4
|
||||||
height: actualIconSize
|
height: actualIconSize - 4
|
||||||
|
|
||||||
|
readonly property string iconPath: Paths.resolveIconPath(TrashService.isEmpty ? "user-trash" : "user-trash-full")
|
||||||
|
|
||||||
IconImage {
|
IconImage {
|
||||||
id: trashIcon
|
id: trashIcon
|
||||||
anchors.centerIn: parent
|
anchors.fill: parent
|
||||||
width: actualIconSize - 4
|
source: parent.iconPath
|
||||||
height: actualIconSize - 4
|
backer.sourceSize: Qt.size(parent.width * 2, parent.height * 2)
|
||||||
smooth: true
|
smooth: true
|
||||||
|
mipmap: true
|
||||||
asynchronous: true
|
asynchronous: true
|
||||||
source: Quickshell.iconPath(TrashService.isEmpty ? "user-trash" : "user-trash-full", "user-trash")
|
visible: status === Image.Ready
|
||||||
|
}
|
||||||
|
|
||||||
Behavior on opacity {
|
DankIcon {
|
||||||
NumberAnimation {
|
anchors.centerIn: parent
|
||||||
duration: Theme.shortDuration
|
visible: parent.iconPath === "" || trashIcon.status !== Image.Ready
|
||||||
easing.type: Easing.OutCubic
|
name: "delete"
|
||||||
}
|
size: actualIconSize - 8
|
||||||
}
|
color: TrashService.isEmpty ? Theme.surfaceText : Theme.primary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,377 +1,55 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
|
||||||
import Quickshell.Wayland
|
|
||||||
import Quickshell.Widgets
|
|
||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Widgets
|
|
||||||
|
|
||||||
PanelWindow {
|
DockContextMenuBase {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
WindowBlur {
|
|
||||||
targetWindow: root
|
|
||||||
blurX: menuContainer.x
|
|
||||||
blurY: menuContainer.y
|
|
||||||
blurWidth: root.visible ? menuContainer.width : 0
|
|
||||||
blurHeight: root.visible ? menuContainer.height : 0
|
|
||||||
blurRadius: Theme.cornerRadius
|
|
||||||
}
|
|
||||||
|
|
||||||
WlrLayershell.namespace: "dms:dock-trash-context-menu"
|
|
||||||
|
|
||||||
property var anchorItem: null
|
|
||||||
property real dockVisibleHeight: 40
|
|
||||||
property int margin: 10
|
|
||||||
property var dockApps: null
|
property var dockApps: null
|
||||||
|
|
||||||
|
layerNamespace: "dms:dock-trash-context-menu"
|
||||||
|
|
||||||
function showForButton(button, dockHeight, dockScreen, parentDockApps) {
|
function showForButton(button, dockHeight, dockScreen, parentDockApps) {
|
||||||
if (dockScreen) {
|
|
||||||
root.screen = dockScreen;
|
|
||||||
}
|
|
||||||
|
|
||||||
anchorItem = button;
|
|
||||||
dockVisibleHeight = dockHeight || 40;
|
|
||||||
dockApps = parentDockApps || null;
|
dockApps = parentDockApps || null;
|
||||||
|
show(button, dockHeight, dockScreen);
|
||||||
visible = true;
|
|
||||||
}
|
|
||||||
function close() {
|
|
||||||
visible = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
screen: null
|
DockTrashMenuItem {
|
||||||
visible: false
|
width: parent.width
|
||||||
WlrLayershell.layer: WlrLayershell.Overlay
|
iconName: "folder_open"
|
||||||
WlrLayershell.exclusiveZone: -1
|
text: I18n.tr("Open Trash")
|
||||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
onTriggered: {
|
||||||
color: "transparent"
|
TrashService.openTrash();
|
||||||
anchors {
|
root.close();
|
||||||
top: true
|
|
||||||
left: true
|
|
||||||
right: true
|
|
||||||
bottom: true
|
|
||||||
}
|
|
||||||
|
|
||||||
property point anchorPos: Qt.point(screen ? screen.width / 2 : 0, screen ? screen.height - 100 : 0)
|
|
||||||
|
|
||||||
onAnchorItemChanged: updatePosition()
|
|
||||||
onVisibleChanged: {
|
|
||||||
if (visible) {
|
|
||||||
updatePosition();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePosition() {
|
DockTrashMenuItem {
|
||||||
if (!anchorItem || !screen) {
|
width: parent.width
|
||||||
anchorPos = Qt.point(screen ? screen.width / 2 : 0, screen ? screen.height - 100 : 0);
|
iconName: "delete_forever"
|
||||||
return;
|
isDestructive: true
|
||||||
|
enabled: !TrashService.isEmpty
|
||||||
|
text: TrashService.isEmpty ? I18n.tr("Empty Trash") : I18n.tr("Empty Trash (%1)").arg(TrashService.count)
|
||||||
|
onTriggered: {
|
||||||
|
TrashService.requestEmptyTrash();
|
||||||
|
root.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
const dockWindow = anchorItem.Window.window;
|
|
||||||
if (!dockWindow) {
|
|
||||||
anchorPos = Qt.point(screen.width / 2, screen.height - 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttonPosInDock = anchorItem.mapToItem(dockWindow.contentItem, 0, 0);
|
|
||||||
let actualDockHeight = root.dockVisibleHeight;
|
|
||||||
|
|
||||||
function findDockBackground(item) {
|
|
||||||
if (item.objectName === "dockBackground") {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
for (var i = 0; i < item.children.length; i++) {
|
|
||||||
const found = findDockBackground(item.children[i]);
|
|
||||||
if (found) {
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dockBackground = findDockBackground(dockWindow.contentItem);
|
|
||||||
let actualDockWidth = dockWindow.width;
|
|
||||||
if (dockBackground) {
|
|
||||||
actualDockHeight = dockBackground.height;
|
|
||||||
actualDockWidth = dockBackground.width;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isVertical = SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right;
|
|
||||||
const dockMargin = SettingsData.dockMargin + 16;
|
|
||||||
let buttonScreenX, buttonScreenY;
|
|
||||||
|
|
||||||
if (isVertical) {
|
|
||||||
const dockContentHeight = dockWindow.height;
|
|
||||||
const screenHeight = root.screen.height;
|
|
||||||
const dockTopMargin = Math.round((screenHeight - dockContentHeight) / 2);
|
|
||||||
buttonScreenY = dockTopMargin + buttonPosInDock.y + anchorItem.height / 2;
|
|
||||||
|
|
||||||
if (SettingsData.dockPosition === SettingsData.Position.Right) {
|
|
||||||
buttonScreenX = root.screen.width - actualDockWidth - dockMargin - 20;
|
|
||||||
} else {
|
|
||||||
buttonScreenX = actualDockWidth + dockMargin + 20;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const isDockAtBottom = SettingsData.dockPosition === SettingsData.Position.Bottom;
|
|
||||||
|
|
||||||
if (isDockAtBottom) {
|
|
||||||
buttonScreenY = root.screen.height - actualDockHeight - dockMargin - 20;
|
|
||||||
} else {
|
|
||||||
buttonScreenY = actualDockHeight + dockMargin + 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dockContentWidth = dockWindow.width;
|
|
||||||
const screenWidth = root.screen.width;
|
|
||||||
const dockLeftMargin = Math.round((screenWidth - dockContentWidth) / 2);
|
|
||||||
buttonScreenX = dockLeftMargin + buttonPosInDock.x + anchorItem.width / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
anchorPos = Qt.point(buttonScreenX, buttonScreenY);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: menuContainer
|
width: parent.width
|
||||||
|
height: 1
|
||||||
x: {
|
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
|
||||||
const isVertical = SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right;
|
|
||||||
if (isVertical) {
|
|
||||||
const isDockAtRight = SettingsData.dockPosition === SettingsData.Position.Right;
|
|
||||||
if (isDockAtRight) {
|
|
||||||
return Math.max(10, root.anchorPos.x - width + 30);
|
|
||||||
} else {
|
|
||||||
return Math.min(root.width - width - 10, root.anchorPos.x - 30);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const left = 10;
|
|
||||||
const right = root.width - width - 10;
|
|
||||||
const want = root.anchorPos.x - width / 2;
|
|
||||||
return Math.max(left, Math.min(right, want));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
y: {
|
|
||||||
const isVertical = SettingsData.dockPosition === SettingsData.Position.Left || SettingsData.dockPosition === SettingsData.Position.Right;
|
|
||||||
if (isVertical) {
|
|
||||||
const top = 10;
|
|
||||||
const bottom = root.height - height - 10;
|
|
||||||
const want = root.anchorPos.y - height / 2;
|
|
||||||
return Math.max(top, Math.min(bottom, want));
|
|
||||||
} else {
|
|
||||||
const isDockAtBottom = SettingsData.dockPosition === SettingsData.Position.Bottom;
|
|
||||||
if (isDockAtBottom) {
|
|
||||||
return Math.max(10, root.anchorPos.y - height + 30);
|
|
||||||
} else {
|
|
||||||
return Math.min(root.height - height - 10, root.anchorPos.y - 30);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
width: Math.min(400, Math.max(180, menuColumn.implicitWidth + Theme.spacingS * 2))
|
|
||||||
height: menuColumn.implicitHeight + Theme.spacingS * 2
|
|
||||||
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
border.color: BlurService.enabled ? BlurService.borderColor : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.08)
|
|
||||||
border.width: BlurService.enabled ? BlurService.borderWidth : 1
|
|
||||||
|
|
||||||
opacity: root.visible ? 1 : 0
|
|
||||||
visible: opacity > 0
|
|
||||||
|
|
||||||
Behavior on opacity {
|
|
||||||
NumberAnimation {
|
|
||||||
duration: Theme.shortDuration
|
|
||||||
easing.type: Theme.emphasizedEasing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.topMargin: 4
|
|
||||||
anchors.leftMargin: 2
|
|
||||||
anchors.rightMargin: -2
|
|
||||||
anchors.bottomMargin: -4
|
|
||||||
radius: parent.radius
|
|
||||||
color: Qt.rgba(0, 0, 0, 0.15)
|
|
||||||
z: -1
|
|
||||||
}
|
|
||||||
|
|
||||||
Column {
|
|
||||||
id: menuColumn
|
|
||||||
width: parent.width - Theme.spacingS * 2
|
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
|
||||||
anchors.top: parent.top
|
|
||||||
anchors.topMargin: Theme.spacingS
|
|
||||||
spacing: 1
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
width: parent.width
|
|
||||||
height: 28
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: openArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.leftMargin: Theme.spacingS
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.rightMargin: Theme.spacingS
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: Theme.spacingXS
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
name: "folder_open"
|
|
||||||
size: 14
|
|
||||||
color: Theme.surfaceText
|
|
||||||
opacity: 0.7
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: I18n.tr("Open Trash")
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceText
|
|
||||||
font.weight: Font.Normal
|
|
||||||
elide: Text.ElideRight
|
|
||||||
wrapMode: Text.NoWrap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DankRipple {
|
|
||||||
id: openRipple
|
|
||||||
rippleColor: Theme.surfaceText
|
|
||||||
cornerRadius: Theme.cornerRadius
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: openArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onPressed: mouse => openRipple.trigger(mouse.x, mouse.y)
|
|
||||||
onClicked: {
|
|
||||||
TrashService.openTrash();
|
|
||||||
root.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
width: parent.width
|
|
||||||
height: 28
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
enabled: !TrashService.isEmpty
|
|
||||||
opacity: enabled ? 1 : 0.4
|
|
||||||
color: emptyArea.containsMouse && enabled ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12) : "transparent"
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.leftMargin: Theme.spacingS
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.rightMargin: Theme.spacingS
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: Theme.spacingXS
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
name: "delete_forever"
|
|
||||||
size: 14
|
|
||||||
color: emptyArea.containsMouse && parent.parent.enabled ? Theme.error : Theme.surfaceText
|
|
||||||
opacity: 0.7
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: TrashService.isEmpty ? I18n.tr("Empty Trash") : I18n.tr("Empty Trash (%1)").arg(TrashService.count)
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: emptyArea.containsMouse && parent.parent.enabled ? Theme.error : Theme.surfaceText
|
|
||||||
font.weight: Font.Normal
|
|
||||||
elide: Text.ElideRight
|
|
||||||
wrapMode: Text.NoWrap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DankRipple {
|
|
||||||
id: emptyRipple
|
|
||||||
rippleColor: Theme.error
|
|
||||||
cornerRadius: Theme.cornerRadius
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: emptyArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
enabled: parent.enabled
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onPressed: mouse => emptyRipple.trigger(mouse.x, mouse.y)
|
|
||||||
onClicked: {
|
|
||||||
TrashService.requestEmptyTrash();
|
|
||||||
root.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
width: parent.width
|
|
||||||
height: 1
|
|
||||||
color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.2)
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
width: parent.width
|
|
||||||
height: 28
|
|
||||||
radius: Theme.cornerRadius
|
|
||||||
color: settingsArea.containsMouse ? BlurService.hoverColor(Theme.widgetBaseHoverColor) : "transparent"
|
|
||||||
|
|
||||||
Row {
|
|
||||||
anchors.left: parent.left
|
|
||||||
anchors.leftMargin: Theme.spacingS
|
|
||||||
anchors.right: parent.right
|
|
||||||
anchors.rightMargin: Theme.spacingS
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
spacing: Theme.spacingXS
|
|
||||||
|
|
||||||
DankIcon {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
name: "settings"
|
|
||||||
size: 14
|
|
||||||
color: Theme.surfaceText
|
|
||||||
opacity: 0.7
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledText {
|
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
|
||||||
text: I18n.tr("Settings")
|
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
|
||||||
color: Theme.surfaceText
|
|
||||||
font.weight: Font.Normal
|
|
||||||
elide: Text.ElideRight
|
|
||||||
wrapMode: Text.NoWrap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DankRipple {
|
|
||||||
id: settingsRipple
|
|
||||||
rippleColor: Theme.surfaceText
|
|
||||||
cornerRadius: Theme.cornerRadius
|
|
||||||
}
|
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
id: settingsArea
|
|
||||||
anchors.fill: parent
|
|
||||||
hoverEnabled: true
|
|
||||||
cursorShape: Qt.PointingHandCursor
|
|
||||||
onPressed: mouse => settingsRipple.trigger(mouse.x, mouse.y)
|
|
||||||
onClicked: {
|
|
||||||
PopoutService.focusOrToggleSettingsWithTab("dock");
|
|
||||||
root.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
DockTrashMenuItem {
|
||||||
anchors.fill: parent
|
width: parent.width
|
||||||
z: -1
|
iconName: "settings"
|
||||||
onClicked: root.close()
|
text: I18n.tr("Settings")
|
||||||
|
onTriggered: {
|
||||||
|
SettingsSearchService.navigateToSection("dockTrash");
|
||||||
|
PopoutService.openSettingsWithTab("dock");
|
||||||
|
root.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
quickshell/Modules/Dock/DockTrashMenuItem.qml
Normal file
69
quickshell/Modules/Dock/DockTrashMenuItem.qml
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Widgets
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property string iconName: ""
|
||||||
|
property string text: ""
|
||||||
|
property bool isDestructive: false
|
||||||
|
property bool enabled: true
|
||||||
|
|
||||||
|
signal triggered
|
||||||
|
|
||||||
|
height: 28
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
opacity: enabled ? 1 : 0.4
|
||||||
|
color: {
|
||||||
|
if (!area.containsMouse || !enabled)
|
||||||
|
return "transparent";
|
||||||
|
if (isDestructive)
|
||||||
|
return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12);
|
||||||
|
return BlurService.hoverColor(Theme.widgetBaseHoverColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Theme.spacingS
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: Theme.spacingS
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
name: root.iconName
|
||||||
|
size: 14
|
||||||
|
color: root.isDestructive && area.containsMouse && root.enabled ? Theme.error : Theme.surfaceText
|
||||||
|
opacity: 0.7
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.text
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: root.isDestructive && area.containsMouse && root.enabled ? Theme.error : Theme.surfaceText
|
||||||
|
font.weight: Font.Normal
|
||||||
|
elide: Text.ElideRight
|
||||||
|
wrapMode: Text.NoWrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankRipple {
|
||||||
|
id: ripple
|
||||||
|
rippleColor: root.isDestructive ? Theme.error : Theme.surfaceText
|
||||||
|
cornerRadius: Theme.cornerRadius
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: area
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
enabled: root.enabled
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onPressed: mouse => ripple.trigger(mouse.x, mouse.y)
|
||||||
|
onClicked: root.triggered()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -558,22 +558,9 @@ Item {
|
|||||||
backgroundColor: Theme.surfaceContainerHighest
|
backgroundColor: Theme.surfaceContainerHighest
|
||||||
normalBorderColor: Theme.outlineMedium
|
normalBorderColor: Theme.outlineMedium
|
||||||
focusedBorderColor: Theme.primary
|
focusedBorderColor: Theme.primary
|
||||||
|
text: SettingsData.dockTrashCustomCommand
|
||||||
Component.onCompleted: {
|
|
||||||
if (SettingsData.dockTrashCustomCommand) {
|
|
||||||
text = SettingsData.dockTrashCustomCommand;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onTextEdited: SettingsData.set("dockTrashCustomCommand", text.trim())
|
onTextEdited: SettingsData.set("dockTrashCustomCommand", text.trim())
|
||||||
|
|
||||||
MouseArea {
|
|
||||||
anchors.fill: parent
|
|
||||||
onPressed: mouse => {
|
|
||||||
trashCustomCommandField.forceActiveFocus();
|
|
||||||
mouse.accepted = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Qt.labs.folderlistmodel
|
import Qt.labs.folderlistmodel
|
||||||
@@ -13,15 +14,16 @@ Singleton {
|
|||||||
readonly property string _xdgDataHome: Quickshell.env("XDG_DATA_HOME") || (_homeDir + "/.local/share")
|
readonly property string _xdgDataHome: Quickshell.env("XDG_DATA_HOME") || (_homeDir + "/.local/share")
|
||||||
readonly property string trashFilesDir: _xdgDataHome + "/Trash/files"
|
readonly property string trashFilesDir: _xdgDataHome + "/Trash/files"
|
||||||
|
|
||||||
readonly property int count: trashModel.count
|
property int count: 0
|
||||||
readonly property bool isEmpty: count === 0
|
readonly property bool isEmpty: count === 0
|
||||||
|
|
||||||
property var availableFileManagers: []
|
property var availableFileManagers: ["default"]
|
||||||
|
property string defaultFileManagerLabel: "default (xdg-open)"
|
||||||
|
|
||||||
signal emptyTrashConfirmRequested(int itemCount)
|
signal emptyTrashConfirmRequested(int itemCount)
|
||||||
|
|
||||||
FolderListModel {
|
FolderListModel {
|
||||||
id: trashModel
|
id: homeTrashModel
|
||||||
folder: "file://" + root.trashFilesDir
|
folder: "file://" + root.trashFilesDir
|
||||||
showDirs: true
|
showDirs: true
|
||||||
showFiles: true
|
showFiles: true
|
||||||
@@ -31,53 +33,85 @@ Singleton {
|
|||||||
nameFilters: ["*"]
|
nameFilters: ["*"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: homeTrashModel
|
||||||
|
function onCountChanged() {
|
||||||
|
root.refreshCount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: detectProc
|
id: detectProc
|
||||||
running: false
|
running: false
|
||||||
command: ["sh", "-c", "for fm in nautilus thunar dolphin; do command -v $fm >/dev/null 2>&1 && echo $fm; done"]
|
command: ["sh", "-c", "for fm in nautilus thunar dolphin nemo caja pcmanfm pcmanfm-qt krusader; do command -v $fm >/dev/null 2>&1 && echo $fm; done"]
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
onStreamFinished: {
|
onStreamFinished: {
|
||||||
const detected = (text || "").split("\n").map(s => s.trim()).filter(s => s.length > 0);
|
const detected = (text || "").split("\n").map(s => s.trim()).filter(s => s.length > 0);
|
||||||
detected.push("custom");
|
root.availableFileManagers = ["default"].concat(detected).concat(["custom"]);
|
||||||
root.availableFileManagers = detected;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
detectProc.running = true;
|
detectProc.running = true;
|
||||||
|
refreshCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshCount() {
|
||||||
|
Proc.runCommand("trash-count", ["dms", "trash", "count"], (output, exitCode) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
root.count = homeTrashModel.count;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = parseInt((output || "").trim(), 10);
|
||||||
|
root.count = isNaN(n) ? homeTrashModel.count : n;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function trashPath(path, callback) {
|
||||||
|
if (!path) {
|
||||||
|
if (callback)
|
||||||
|
callback(false, "empty path");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Proc.runCommand(null, ["dms", "trash", "put", path], (output, exitCode) => {
|
||||||
|
const ok = exitCode === 0;
|
||||||
|
if (!ok)
|
||||||
|
ToastService.showError(I18n.tr("Failed to move to trash"), path);
|
||||||
|
refreshCount();
|
||||||
|
if (callback)
|
||||||
|
callback(ok, output);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTrash() {
|
function openTrash() {
|
||||||
const choice = SettingsData.dockTrashFileManager || "nautilus";
|
const choice = SettingsData.dockTrashFileManager || "default";
|
||||||
if (choice === "custom") {
|
switch (choice) {
|
||||||
const cmd = (SettingsData.dockTrashCustomCommand || "").trim();
|
case "default":
|
||||||
if (!cmd) {
|
Quickshell.execDetached(["xdg-open", "trash:///"]);
|
||||||
ToastService.showInfo(I18n.tr("Cannot open trash: no custom command set"), I18n.tr("Configure one in Settings → Dock → Trash."));
|
return;
|
||||||
return;
|
case "custom":
|
||||||
}
|
openCustom();
|
||||||
Proc.runCommand(null, ["sh", "-c", cmd], (output, exitCode) => {
|
|
||||||
if (exitCode !== 0) {
|
|
||||||
ToastService.showError(I18n.tr("Trash command failed (exit %1)").arg(exitCode), I18n.tr("Check your custom command in Settings → Dock → Trash."));
|
|
||||||
}
|
|
||||||
}, 0, Proc.noTimeout);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (availableFileManagers.indexOf(choice) < 0) {
|
if (availableFileManagers.indexOf(choice) < 0) {
|
||||||
ToastService.showInfo(I18n.tr("Cannot open trash: '%1' is not installed").arg(choice), I18n.tr("Pick a different file manager in Settings → Dock → Trash."));
|
ToastService.showInfo(I18n.tr("Cannot open trash: '%1' is not installed").arg(choice), I18n.tr("Pick a different file manager in Settings → Dock → Trash."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
switch (choice) {
|
Quickshell.execDetached([choice, "trash:///"]);
|
||||||
case "nautilus":
|
}
|
||||||
Quickshell.execDetached(["nautilus", "trash:///"]);
|
|
||||||
break;
|
function openCustom() {
|
||||||
case "thunar":
|
const cmd = (SettingsData.dockTrashCustomCommand || "").trim();
|
||||||
Quickshell.execDetached(["thunar", "trash:///"]);
|
if (!cmd) {
|
||||||
break;
|
ToastService.showInfo(I18n.tr("Cannot open trash: no custom command set"), I18n.tr("Configure one in Settings → Dock → Trash."));
|
||||||
case "dolphin":
|
return;
|
||||||
Quickshell.execDetached(["dolphin", "trash:///"]);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
Proc.runCommand(null, ["sh", "-c", cmd], (output, exitCode) => {
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
ToastService.showError(I18n.tr("Trash command failed (exit %1)").arg(exitCode), I18n.tr("Check your custom command in Settings → Dock → Trash."));
|
||||||
|
}
|
||||||
|
}, 0, Proc.noTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestEmptyTrash() {
|
function requestEmptyTrash() {
|
||||||
@@ -87,6 +121,10 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emptyTrash() {
|
function emptyTrash() {
|
||||||
Quickshell.execDetached(["gio", "trash", "--empty"]);
|
Proc.runCommand("trash-empty", ["dms", "trash", "empty"], (output, exitCode) => {
|
||||||
|
if (exitCode !== 0)
|
||||||
|
ToastService.showError(I18n.tr("Failed to empty trash"), output || "");
|
||||||
|
refreshCount();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user