     /// reverse selected lines (only in the first selection)
     @IBAction func reverseLines(_ sender: Any?) {
         
-        let string = self.string as NSString
-        
         // process whole document if no text selected
-        if self.selectedRange.length == 0 {
-            self.selectedRange = string.range
-        }
-        
-        let lineRange = string.lineRange(for: self.selectedRange, excludingLastLineEnding: true)
-        
-        guard lineRange.length > 0 else { return }
-        
-        let lines = string.substring(with: lineRange).components(separatedBy: .newlines)
-        
-        // do nothing with single line
-        guard lines.count > 1 else { return }
-        
-        // make new string
-        let newString = lines.reversed().joined(separator: "\n")
-        
-        self.replace(with: newString, range: lineRange, selectedRange: lineRange,
-                     actionName: "Reverse Lines".localized)
+        let range = (self.selectedRange.length == 0) ? self.string.nsRange : self.selectedRange
+        
+        guard let editingInfo = self.string.reverseLines(in: range) else { return }
+        
+        self.edit(with: editingInfo, actionName: "Reverse Lines".localized)
     }
     
     
     /// delete duplicate lines in selection
     @IBAction func deleteDuplicateLine(_ sender: Any?) {
         
+        // process whole document if no text selected
+        let ranges = (self.selectedRange.length == 0) ? [self.string.nsRange] : self.selectedRanges as! [NSRange]
+        
+        guard let editingInfo = self.string.deleteDuplicateLine(in: ranges) else { return }
+        
+        self.edit(with: editingInfo, actionName: "Delete Duplicate Lines".localized)
+    }
+    
+    
+    /// duplicate selected lines below
+    @IBAction func duplicateLine(_ sender: Any?) {
+        
+        guard let editingInfo = self.string.duplicateLine(in: self.selectedRanges as! [NSRange]) else { return }
+        
+        self.edit(with: editingInfo, actionName: "Duplicate Line".localized)
+    }
+    
+    
+    /// remove selected lines
+    @IBAction func deleteLine(_ sender: Any?) {
+        
+        guard let editingInfo = self.string.deleteLine(in: self.selectedRanges as! [NSRange]) else { return }
+        
+        self.edit(with: editingInfo, actionName: "Delete Line".localized)
+    }
+    
+    
+    /// trim all trailing whitespace
+    @IBAction func trimTrailingWhitespace(_ sender: Any?) {
+        
+        let trimsWhitespaceOnlyLines = UserDefaults.standard[.trimsWhitespaceOnlyLines]
+        
+        self.trimTrailingWhitespace(ignoresEmptyLines: !trimsWhitespaceOnlyLines)
+    }
+    
+    
+    
+    // MARK: Private Methods
+    
+    /// replace content according to EditingInfo
+    private func edit(with info: String.EditingInfo, actionName: String) {
+        
+        self.replace(with: info.strings, ranges: info.ranges, selectedRanges: info.selectedRanges, actionName: actionName)
+    }
+    
+}
+
+
+
+extension NSTextView {
+    
+    func sortLines(pattern: SortPattern, options: SortOptions) {
+        
+        // process whole document if no text selected
+        let range = (self.selectedRange.length == 0) ? self.string.nsRange : self.selectedRange
+        
         let string = self.string as NSString
-        
-        // process whole document if no text selected
-        if self.selectedRange.length == 0 {
-            self.selectedRange = string.range
-        }
-        
-        guard self.selectedRange.length > 0 else { return }
-        
+        let lineRange = string.lineRange(for: range, excludingLastLineEnding: true)
+        
+        guard lineRange.length > 0 else { return }
+        
+        let newString = pattern.sort(string.substring(with: lineRange), options: options)
+        
+        self.replace(with: newString, range: lineRange, selectedRange: lineRange,
+                     actionName: "Sort Lines".localized)
+    }
+    
+}
+
+
+
+
+// MARK: -
+
+private extension String {
+    
+    typealias EditingInfo = (strings: [String], ranges: [NSRange], selectedRanges: [NSRange]?)
+    
+    
+    
+    /// sort selected lines ascending
+    func sortLinesAscending(in range: NSRange) -> EditingInfo? {
+        
+        let string = self as NSString
+        let lineRange = string.lineRange(for: range, excludingLastLineEnding: true)
+        
+        // do nothing with single line
+        guard string.rangeOfCharacter(from: .newlines, range: lineRange) != .notFound else { return nil }
+        
+        let newString = string
+            .substring(with: lineRange)
+            .components(separatedBy: .newlines)
+            .sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
+            .joined(separator: "\n")
+        
+        return (strings: [newString], ranges: [lineRange], selectedRanges: [lineRange])
+    }
+    
+    
+    /// reverse selected lines
+    func reverseLines(in range: NSRange) -> EditingInfo? {
+        
+        let string = self as NSString
+        let lineRange = string.lineRange(for: range, excludingLastLineEnding: true)
+        
+        // do nothing with single line
+        guard string.rangeOfCharacter(from: .newlines, range: lineRange) != .notFound else { return nil }
+        
+        let newString = string
+            .substring(with: lineRange)
+            .components(separatedBy: .newlines)
+            .reversed()
+            .joined(separator: "\n")
+        
+        return (strings: [newString], ranges: [lineRange], selectedRanges: [lineRange])
+    }
+    
+    
+    /// delete duplicate lines in selection
+    func deleteDuplicateLine(in ranges: [NSRange]) -> EditingInfo? {
+        
+        let string = self as NSString
+        var replacementStrings = [String]()
         var replacementRanges = [NSRange]()
-        var replacementStrings = [String]()
         var uniqueLines = OrderedSet<String>()
         var processedCount = 0
         
         // collect duplicate lines
-        for range in self.selectedRanges as! [NSRange] {
+        for range in ranges {
             let lineRange = string.lineRange(for: range, excludingLastLineEnding: true)
             let targetString = string.substring(with: lineRange)
             let lines = targetString.components(separatedBy: .newlines)

            return
        }
        
        let selectedRanges = self.selectedRanges as! [NSRange]
        
        // register redo for text selection
        self.setSelectedRangesWithUndo(self.selectedRanges)
        
        var newSelectedRanges = [NSRange]()
        
        // swap lines
        textStorage.beginEditing()

            self.didChangeText()
            
            // move selected ranges in the line to move
            for selectedRange in ranges {
                if let intersectionRange = selectedRange.intersection(editRange) {
                    selectedRanges.append(NSRange(location: intersectionRange.location - upperLineRange.length,
                                                  length: intersectionRange.length))
                    
                } else if editRange.contains(selectedRange.location) || selectedRange.upperBound == editRange.upperBound {
                    selectedRanges.append(NSRange(location: selectedRange.location - upperLineRange.length,
                                                  length: selectedRange.length))
                }
            }
        }
        textStorage.endEditing()
        
        self.setSelectedRangesWithUndo(selectedRanges)
        
        self.undoManager?.setActionName("Move Line".localized)
    }
